@Asset(kind, map): a path under each map's directory - ludicc --check looks for it in every map (a @PerMap row's in its own, a game-wide row's in all), unless @Asset(kind, map, optional); the schema marks it scope map; reseeded

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 22:13:14 +03:00
parent 9cc67fee74
commit af90a1a334
10 changed files with 79959 additions and 77965 deletions

View file

@ -731,6 +731,7 @@ property Tool {
@OneOf(GR_GOLD, GR_SILVER) medal: int = 0 # or one of these constants @OneOf(GR_GOLD, GR_SILVER) medal: int = 0 # or one of these constants
@Range(0, 20.5) @Unit("kg") weight: float = 1.0 @Range(0, 20.5) @Unit("kg") weight: float = 1.0
@Asset("gltf") model: string = "" # a file of that kind (any string) @Asset("gltf") model: string = "" # a file of that kind (any string)
@Asset("png", map) density: string = "" # a path under EACH map's directory (assets/maps/<key>/...)
@Color tint: int = 0 @Color tint: int = 0
@Node(model) grip: string = "" # a node inside the glTF that `model` names @Node(model) grip: string = "" # a node inside the glTF that `model` names
@Clip(model) swing: string = "" # a clip inside it @Clip(model) swing: string = "" # a clip inside it
@ -759,7 +760,11 @@ Everything else is an error, every one reported: a target that exists but is ano
arguments are words (`@OneOf("box", "hull")`) and every registry row's value must be one of them, on arguments are words (`@OneOf("box", "hull")`) and every registry row's value must be one of them, on
any other field a prefix ending in `_` or constants; and `@Node(f)`, `@Clip(f)` or `@Material(f)` any other field a prefix ending in `_` or constants; and `@Node(f)`, `@Clip(f)` or `@Material(f)`
naming anything but a field `f` of the same record that is `@Asset("gltf")`, or an `@Ref` to a naming anything but a field `f` of the same record that is `@Asset("gltf")`, or an `@Ref` to a
registry whose record has exactly one `@Asset("gltf")` field (the model is then the row's). registry whose record has exactly one `@Asset("gltf")` field (the model is then the row's). `@Asset(kind, map)` names a file under each map's
directory, not the game's root: `ludicc --check` looks for it in every map - a @PerMap row's in its own
map, a game-wide row's in all of them - and refuses a map that lacks it, unless the field says
`@Asset(kind, map, optional)`; the schema marks it `"scope": "map"`. A plain `@Asset(kind)` is not
looked for.
`ludicc app.ludic --emit-schema out.json` (or `ludic schema [file] [-o out.json]`) writes what the `ludicc app.ludic --emit-schema out.json` (or `ludic schema [file] [-o out.json]`) writes what the
compiler resolved, once the types are checked and every open registry has its entries, as one JSON compiler resolved, once the types are checked and every open registry has its entries, as one JSON

6
changes/asset-map.md Normal file
View file

@ -0,0 +1,6 @@
bump: minor
type: feature
**`@Asset(kind, map)`: a path under each map's directory.** A field whose file lives in the map's own folder
(a grass kind's density picture, `ground/blades.png`) says so, and `ludicc --check` looks for it in every
map - a @PerMap row's in its map, a game-wide row's in all of them - refusing a map that lacks it unless the
field is `@Asset(kind, map, optional)`. The schema marks the attribute `"scope": "map"`.

View file

@ -1,7 +1,8 @@
# permap_maps.ludic - `ludicc --check` reads every map directory and checks its tables against their # permap_maps.ludic - `ludicc --check` reads every map directory and checks its tables against their
# records. maps/m1 holds five mistakes, each refused at its file, line and column: a field the # records. maps/m1 holds six mistakes, each refused at its file, line and column: a field the record
# record lacks, a value of the wrong type, a constant nobody declared, a key naming no row, and a # lacks, a value of the wrong type, a constant nobody declared, a key naming no row, a chunked table's
# chunked table's key written in two of the map's chunk files (t1, in trees/-1_0 and trees/0_0). # key written in two chunk files (t1, in trees/-1_0 and trees/0_0), and a game-wide row's
# @Asset(..., map) file the map lacks (blades.png; the optional one, wild.png, may be missing).
program PerMapMaps { program PerMapMaps {
numbers float numbers float
const MDL_TENT: int = 7 const MDL_TENT: int = 7
@ -15,6 +16,13 @@ program PerMapMaps {
} }
@PerMap @ByKey registry Props of PropRow from "props.lres" @PerMap @ByKey registry Props of PropRow from "props.lres"
property Tree { key: string = "", x: float = 0.0 } property Tree { key: string = "", x: float = 0.0 }
property Grass {
key: string = ""
@Asset("png", map) density: string = ""
@Asset("png", map, optional) wild: string = ""
}
registry Grasses of Grass
def Grasses meadow { density: "blades.png", wild: "wild.png" }
@PerMap @Chunked(64) registry Trees of Tree from "trees/{cx}_{cz}.lres" @PerMap @Chunked(64) registry Trees of Tree from "trees/{cx}_{cz}.lres"
entry (props: mut Props) { entry (props: mut Props) {
print(`{props_load(props, "m1")}`) print(`{props_load(props, "m1")}`)

View file

@ -191,6 +191,7 @@ function sc_attrs(b: Buf, g: int) -> void {
buf_puts(b, ", \"args\": ") buf_puts(b, ", \"args\": ")
buf_puts(b, g_at_args[j]) buf_puts(b, g_at_args[j])
if g_at_unres[j] { buf_puts(b, ", \"unresolved\": true") } if g_at_unres[j] { buf_puts(b, ", \"unresolved\": true") }
if (g_at_name[j] == "Asset") and len(at_arg_names(g_at_args[j])) >= 2 { buf_puts(b, ", \"scope\": \"map\"") }
if (g_at_name[j] == "Ref") and g_at_first[j] != null and pm_named(g_at_first[j]) { buf_puts(b, ", \"scope\": \"map\"") } if (g_at_name[j] == "Ref") and g_at_first[j] != null and pm_named(g_at_first[j]) { buf_puts(b, ", \"scope\": \"map\"") }
buf_puts(b, "}") buf_puts(b, "}")
j += 1 j += 1

View file

@ -135,11 +135,21 @@ function at_field(g: int, a: pointer) -> void {
g_at_strs_next = (a == "OneOf") and g_at_allstr g_at_strs_next = (a == "OneOf") and g_at_allstr
if (a == "Range") and n != 2 { perr("@Range takes the least and the most: @Range(0, 1.5)") } if (a == "Range") and n != 2 { perr("@Range takes the least and the most: @Range(0, 1.5)") }
if (a == "Range") and not g_at_allnum { perr("@Range takes two numbers: @Range(0, 1.5)") } if (a == "Range") and not g_at_allnum { perr("@Range takes two numbers: @Range(0, 1.5)") }
if ((a == "Unit") or (a == "Asset")) and (n != 1 or toks[pi - 2].kind != TK_STR) { perr(`@{a} takes one string: @{a}("m/s")`) } if (a == "Unit") and (n != 1 or toks[pi - 2].kind != TK_STR) { perr(`@{a} takes one string: @{a}("m/s")`) }
if (a == "Asset") and not at_asset_ok(args, n) { perr("@Asset takes a kind, and `map` for a path under each map's directory (then `optional`): @Asset(\"png\"), @Asset(\"png\", map), @Asset(\"png\", map, optional)") }
if a == "Unit" { at_unit_check(g_at_arg0, toks[pi - 2]) } if a == "Unit" { at_unit_check(g_at_arg0, toks[pi - 2]) }
if at_flag(a) and n != 0 { perr(`@{a} takes nothing`) } if at_flag(a) and n != 0 { perr(`@{a} takes nothing`) }
at_push(g, a, args, g_at_arg0, n) at_push(g, a, args, g_at_arg0, n)
} }
# @Asset("png"), @Asset("png", map) or @Asset("png", map, optional)
function at_asset_ok(args: pointer, n: int) -> bool {
if n < 1 or n > 3 { return false }
let w = at_arg_names(args)
if len(w) != n { return false }
if n >= 2 and not (w[1] == "map") { return false }
if n == 3 and not (w[2] == "optional") { return false }
return true
}
# a field's group, made the first time it needs one # a field's group, made the first time it needs one
function at_field_group(f: Node) -> int { function at_field_group(f: Node) -> int {
if f.at < 0 { f.at = at_group() } if f.at < 0 { f.at = at_group() }

View file

@ -167,3 +167,18 @@ function at_decl_named(name: pointer) -> bool {
} }
return false return false
} }
# a field's @Asset: 0 none or a game path, 1 a path under each map's directory, 2 the same and optional
function at_asset_map(f: Node) -> int {
if f.at < 0 { return 0 }
var j = g_at_start[f.at]
while j < g_at_start[f.at] + g_at_count[f.at] {
if g_at_name[j] == "Asset" {
let w = at_arg_names(g_at_args[j])
if len(w) >= 3 { return 2 }
if len(w) == 2 { return 1 }
return 0
}
j += 1
}
return 0
}

View file

@ -141,6 +141,7 @@ var g_pmk_ref_reg: []pointer = new []pointer # per cross-row @Ref met: the tabl
var g_pmk_ref_key: []pointer = new []pointer var g_pmk_ref_key: []pointer = new []pointer
var g_pmk_ref_at: []Node = new []Node var g_pmk_ref_at: []Node = new []Node
var g_pmk_mod: pointer = "" # the module of the registry being checked var g_pmk_mod: pointer = "" # the module of the registry being checked
var g_pmk_md: pointer = "" # the map directory being checked
function pmk_read(r: int, path: pointer) -> void { function pmk_read(r: int, path: pointer) -> void {
let src = read_file(path) let src = read_file(path)
@ -186,6 +187,7 @@ function pm_check_maps() -> void {
} }
} }
function pmk_map(md: pointer) -> void { function pmk_map(md: pointer) -> void {
g_pmk_md = md
g_pmk_reg = new []int g_pmk_reg = new []int
g_pmk_key = new []pointer g_pmk_key = new []pointer
g_pmk_rec = new []Node g_pmk_rec = new []Node
@ -217,6 +219,7 @@ function pmk_map(md: pointer) -> void {
e += 1 e += 1
} }
pmk_refs() pmk_refs()
pmk_game_assets()
} }
# a key written twice in one file # a key written twice in one file
function pmk_dups() -> void { function pmk_dups() -> void {
@ -414,6 +417,57 @@ function pmk_has_dot(t: pointer) -> bool {
return false return false
} }
# ---- map-relative assets: @Asset("png", map) -----------------------------------------------------
function pmk_exists(p: pointer) -> bool {
let fh = file_open(p, "rb")
if fh == null { return false }
file_close(fh)
return true
}
# a value of an @Asset(kind, map) field, at node at: the file is in this map's directory, unless optional
function pmk_asset(at: Node, f: Node, v: pointer) -> void {
let m = at_asset_map(f)
if m != 1 { return }
let p = `{g_pmk_md}/{v}`
if not pmk_exists(p) { pmk_err_at(at, `{f.s} is "{v}", and this map has no {p} - @Asset(..., map) is a path under each map's directory`) }
}
# a game-wide registry's rows whose @Asset(kind, map) field names a file: that file in every map
function pmk_game_assets() -> void {
var r = 0
while r < len(g_rg_name) {
if not pm_is(r) { pmk_game_asset_reg(r) }
r += 1
}
}
function pmk_game_asset_reg(r: int) -> void {
let comp = find_comp(g_rg_type[r])
if comp == null { return }
var d = 0
while d < len(g_df_rec) {
if (g_df_reg[d] == g_rg_name[r]) { pmk_game_asset_row(comp, g_df_rec[d]) }
d += 1
}
}
function pmk_game_asset_row(comp: Node, rec: Node) -> void {
var k = 0
while k < len(rec.kids) {
let fi = rec.kids[k]
if fi.kind == E_FINIT and fi.a != null and fi.a.kind == E_STR and len(fi.a.s) > 0 {
let f = pmk_field_of(comp, fi.s)
if f != null { pmk_asset(fi, f, fi.a.s) }
}
k += 1
}
}
function pmk_field_of(comp: Node, name: pointer) -> Node {
var i = 0
while i < len(comp.kids) {
if comp.kids[i].s != null and (comp.kids[i].s == name) { return comp.kids[i] }
i += 1
}
return null
}
# ---- attributes --------------------------------------------------------------------------------- # ---- attributes ---------------------------------------------------------------------------------
function pmk_attrs_str(e: Node, f: Node) -> void { function pmk_attrs_str(e: Node, f: Node) -> void {
if f.at < 0 { return } if f.at < 0 { return }
@ -424,6 +478,7 @@ function pmk_attrs_str(e: Node, f: Node) -> void {
let words = at_arg_names(g_at_args[j]) let words = at_arg_names(g_at_args[j])
if not at_word_in(e.s, words) { pmk_err_at(e, `{f.s} is "{e.s}", and it is @OneOf({at_words_text(words)})`) } if not at_word_in(e.s, words) { pmk_err_at(e, `{f.s} is "{e.s}", and it is @OneOf({at_words_text(words)})`) }
} }
if (a == "Asset") and len(e.s) > 0 { pmk_asset(e, f, e.s) }
if (a == "Ref") and pm_named(g_at_first[j]) and len(e.s) > 0 { if (a == "Ref") and pm_named(g_at_first[j]) and len(e.s) > 0 {
push(g_pmk_ref_reg, g_at_first[j]) push(g_pmk_ref_reg, g_at_first[j])
push(g_pmk_ref_key, e.s) push(g_pmk_ref_key, e.s)

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1278,11 +1278,12 @@ function cmd_dev_test() -> int {
schema_case("lang/permap/permap", "\"units\": [\"m\", \"m/s\", \"m/s2\"", "--emit-schema: the canonical @Unit spellings") schema_case("lang/permap/permap", "\"units\": [\"m\", \"m/s\", \"m/s2\"", "--emit-schema: the canonical @Unit spellings")
reject_case("rejected/permap_ref_int", "names a @PerMap registry, whose rows have no index a program can hold - use a string key", "@Ref into a @PerMap registry is on a string field") reject_case("rejected/permap_ref_int", "names a @PerMap registry, whose rows have no index a program can hold - use a string key", "@Ref into a @PerMap registry is on a string field")
reject_case("rejected/permap_prefix", "a @PerMap registry has no 'as PREFIX'", "a @PerMap registry has no prefix and no constants") reject_case("rejected/permap_prefix", "a @PerMap registry has no 'as PREFIX'", "a @PerMap registry has no prefix and no constants")
diag_json_case("rejected/permap_maps/permap_maps", 5, "\"line\": 2, \"col\": 18, \"severity\": \"error\", \"message\": \"PropRow has no field colour\"", "--check reads every map: a field its record lacks, at the map file's line and column") diag_json_case("rejected/permap_maps/permap_maps", 6, "\"line\": 2, \"col\": 18, \"severity\": \"error\", \"message\": \"PropRow has no field colour\"", "--check reads every map: a field its record lacks, at the map file's line and column")
diag_json_case("rejected/permap_maps/permap_maps", 5, "model wants an int, not a string", "--check reads every map: a value of the wrong type") diag_json_case("rejected/permap_maps/permap_maps", 6, "model wants an int, not a string", "--check reads every map: a value of the wrong type")
diag_json_case("rejected/permap_maps/permap_maps", 5, "unknown constant MDL_BENCH", "--check reads every map: a constant nobody declared") diag_json_case("rejected/permap_maps/permap_maps", 6, "unknown constant MDL_BENCH", "--check reads every map: a constant nobody declared")
diag_json_case("rejected/permap_maps/permap_maps", 5, "there is no Spots row nowhere in this map", "--check reads every map: a cross-row @Ref names a row of the same map") diag_json_case("rejected/permap_maps/permap_maps", 6, "there is no Spots row nowhere in this map", "--check reads every map: a cross-row @Ref names a row of the same map")
diag_json_case("rejected/permap_maps/permap_maps", 5, "the key t1 is also written at", "--check reads every map: a chunked table's key is unique across the map's chunk files, both places named") diag_json_case("rejected/permap_maps/permap_maps", 6, "the key t1 is also written at", "--check reads every map: a chunked table's key is unique across the map's chunk files, both places named")
diag_json_case("rejected/permap_maps/permap_maps", 6, "this map has no examples/rejected/permap_maps/maps/m1/blades.png", "--check reads every map: a game-wide row's @Asset(..., map) file is in each map's directory, unless optional")
feat_case("lang/defaults", "", "a 300.0 8.0 / b 120.0 8.0 / c! 300.0 8.0 / d 300.0 2.0 / x / y", "defaults.ludic (L11: default parameters, positional then named arguments, in a generic too)") feat_case("lang/defaults", "", "a 300.0 8.0 / b 120.0 8.0 / c! 300.0 8.0 / d 300.0 2.0 / x / y", "defaults.ludic (L11: default parameters, positional then named arguments, in a generic too)")
reject_case("rejected/default_missing", "this call to f leaves out a, which has no default", "a call leaves out only what has a default") reject_case("rejected/default_missing", "this call to f leaves out a, which has no default", "a call leaves out only what has a default")
reject_case("rejected/default_unknown", "f has no parameter c", "a named argument names a parameter the function has") reject_case("rejected/default_unknown", "f has no parameter c", "a named argument names a parameter the function has")