Merge commit 'f864f94' into lang/foundations (@Asset(kind, map[, optional]) checked under every map's directory; ludic build --check reads the maps only for the package's entry)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 22:15:46 +03:00
commit 03ecffbf52
12 changed files with 79989 additions and 77971 deletions

View file

@ -715,7 +715,9 @@ field's type, `@OneOf` and `@Range` hold, an `@Ref` into a game registry is in r
into a `@PerMap` registry names a row of that table in the same map (in any of its chunks; `""` is into a `@PerMap` registry names a row of that table in the same map (in any of its chunks; `""` is
none). A key is written once in a file. Errors carry the map file's line and column, through the none). A key is written once in a file. Errors carry the map file's line and column, through the
diagnostics as any other (`--diagnostics=json`); `--no-maps` leaves the maps alone, and no maps diagnostics as any other (`--diagnostics=json`); `--no-maps` leaves the maps alone, and no maps
root is nothing to check. `LUDIC_PERMAP_SRC=<file>` appends what the compiler wrote for each table. root is nothing to check. `ludic build --check` reads them only for the package's `entry` (the game):
a partial program - a unit test, a molecule, a bake's runner - lacks the game's constants and cannot
judge them, so they are left alone there unless `--maps` asks. `LUDIC_PERMAP_SRC=<file>` appends what the compiler wrote for each table.
### Editor attributes and the schema (`@Ref`, `@Range`, ..., `ludic schema`) ### Editor attributes and the schema (`@Ref`, `@Range`, ..., `ludic schema`)
@ -731,6 +733,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 +762,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

@ -0,0 +1,7 @@
bump: patch
type: fix
**`ludic build --check` reads the maps only for the package's entry.** A partial program - a unit test, a
molecule, a bake's runner - lacks the game's constants, so checking every map's tables against it refused
rows the game reads fine (`TH_*`, `NPCK_*` unknown). The maps are now checked with the `entry` package.ludic
names (as `ludic build --check` with no file, and tests/pack.sh, do); `--maps` checks them anyway, `--no-maps`
never. `ludicc` itself is unchanged: it reads them unless told `--no-maps`.

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

@ -278,6 +278,7 @@ function parse_build_args(start: int) -> pointer {
else if a == "--unsafe" { g_unsafe_build = true } else if a == "--unsafe" { g_unsafe_build = true }
else if a == "--check" { g_check_build = true } else if a == "--check" { g_check_build = true }
else if a == "--no-maps" { g_no_maps_build = true } # --check leaves the map directories alone else if a == "--no-maps" { g_no_maps_build = true } # --check leaves the map directories alone
else if a == "--maps" { g_maps_build = true } # ... or reads them, whatever program it checks
else if a == "--diagnostics=json" { g_check_build = true; g_diag_json_build = true } # every error, as JSON on stdout else if a == "--diagnostics=json" { g_check_build = true; g_diag_json_build = true } # every error, as JSON on stdout
else if a == "-o" { else if a == "-o" {
ai += 1 ai += 1
@ -286,7 +287,7 @@ function parse_build_args(start: int) -> pointer {
} }
else if a[0] == '-' { else if a[0] == '-' {
err(`ludic: unknown option {a}\n`) err(`ludic: unknown option {a}\n`)
err(" build/run take: [file] [--headless|--windowed] [-o out] [--save-temps] [--check [--diagnostics=json] [--no-maps]]\n") err(" build/run take: [file] [--headless|--windowed] [-o out] [--save-temps] [--check [--diagnostics=json] [--maps|--no-maps]]\n")
g_argerr = true g_argerr = true
} }
else { src = a } else { src = a }
@ -311,17 +312,31 @@ function output_path(entry: pointer) -> pointer {
var g_check_build: bool = false var g_check_build: bool = false
var g_diag_json_build: bool = false # --diagnostics=json: the compiler's JSON array is the whole stdout var g_diag_json_build: bool = false # --diagnostics=json: the compiler's JSON array is the whole stdout
var g_no_maps_build: bool = false var g_no_maps_build: bool = false
function no_maps_flag() -> pointer { var g_maps_build: bool = false
# the maps are checked with the package's entry - the game - and left alone for any other program: a
# partial program (a unit test, a bake's runner) lacks the game's constants and cannot judge them
function no_maps_flag(src: pointer) -> pointer {
if g_no_maps_build { return " --no-maps" } if g_no_maps_build { return " --no-maps" }
return "" if g_maps_build { return "" }
if not file_exists("package.ludic") { return "" }
let m = read_root_manifest()
if m.entry == "" or bld_same_path(m.entry, src) { return "" }
return " --no-maps"
}
function bld_same_path(a: pointer, b: pointer) -> bool {
var x = a
var y = b
if len(x) > 2 and x[0] == '.' and x[1] == '/' { x = x[2..len(x)] }
if len(y) > 2 and y[0] == '.' and y[1] == '/' { y = y[2..len(y)] }
return x == y
} }
function check_app(src: pointer) -> int { function check_app(src: pointer) -> int {
ensure_ludicc() ensure_ludicc()
if g_diag_json_build { if g_diag_json_build {
if not shq(`{ludicc()} --check --diagnostics=json{no_maps_flag()}{title_flag()}{unsafe_flag()} {src}`) { return 1 } if not shq(`{ludicc()} --check --diagnostics=json{no_maps_flag(src)}{title_flag()}{unsafe_flag()} {src}`) { return 1 }
return 0 return 0
} }
if not shq(`{ludicc()} --check{no_maps_flag()}{title_flag()}{unsafe_flag()} {src}`) { return 1 } if not shq(`{ludicc()} --check{no_maps_flag(src)}{title_flag()}{unsafe_flag()} {src}`) { return 1 }
print(`checked {src}`) print(`checked {src}`)
return 0 return 0
} }

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")