From d0f4d17b652dbb962a7a3d060402b91ed48646ce Mon Sep 17 00:00:00 2001 From: calic Date: Thu, 28 May 2026 23:09:24 +0200 Subject: [PATCH] =?UTF-8?q?maps=20v0.5.1=20=E2=80=94=20opaque-flag=20+=20m?= =?UTF-8?q?anifest=20fields=20+=20override=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small follow-ups bundled into a single version bump: 1. Real tile.opaque consumption (cell_is_opaque_on_layer) v0.5.0e shipped a slot-13 heuristic for vertex-painted layers because the atlas-baker wasn't computing real opacity. Atlas-baker v0.2.0 now sets tile.opaque via alpha-analysis (all-alpha-255 detection). Both vertex-painted and tiles[] paths now resolve the actual slot and read its tile.opaque from atlas metadata. Pre- v0.2.0 atlases without the flag report not-opaque (safe- conservative: extra draws, no missed cells). 2. Manifest-schema patch (reserved fields, accept-but-ignore) Type-checked acceptance of forthcoming manifest fields the validator will need before consumers can ship them in real manifests without breaking changes: map-level: z_level (int), z_below (string) per-layer: base_color (string #RRGGBB), tint_override (table), collision_policy (table), traversal_modes (table), foundation_mode (array) Future slices (lib-core.maps v0.6 multi-z-level, lib-core.actor movement-modes, etc.) wire the fields into actual behaviour. 3. Override format extension {slot, rot, flip} (design paper §15.1) Override entries now accept EITHER a bare integer slot_id (compact canonical-orientation form, backwards-compat with 0.5.0d) OR an object `{slot, rot?, flip?}` for explicit-transform placement (unblocks map-editor v0.2's Direct-mode Transform controls). New normalize_override_entry helper converts disk-form to runtime {slot, rot, flip}. Validator (validate_map_table_v3) type-checks per-entry. set_override accepts either form and auto-stores as compact (bare-int) when rot+flip both 0, object form otherwise — minimises disk diff for the common canonical case. get_override returns normalized form regardless of how the entry is stored. draw_layer + cell_is_opaque_on_layer updated to read the normalized form. Closure-gate: milestone-check.sh ctest + test-all-modules GREEN. SPOREL_CI=1 vagrant-skeleton rc=0 with 60 render_frame_ok; no magenta-placeholders, no render-hook errors. Existing override-cells (vagrant's 3 wall pillars) still render correctly through the new normalize path; opaque-ceiling cache now reads real atlas data. Consumer dep-bumps for v0.5.1 land in their own commits per repo: sporel-lib-core.render, sporel-module-vagrant-skeleton, sporel- module-map-editor[-test]. Co-Authored-By: Claude Opus 4.7 (1M context) --- init.lua | 218 ++++++++++++++++++++++++++++++++++++++++++--------- manifest.lib | 2 +- 2 files changed, 182 insertions(+), 38 deletions(-) diff --git a/init.lua b/init.lua index be11b5f..f848286 100644 --- a/init.lua +++ b/init.lua @@ -295,30 +295,61 @@ local function atlas_slot_index(atlas) return atlas._slot_index end --- 0.5.0e: predicate "is cell (x, y) opaque on layer_name". Used by the --- opaque-ceiling cache. Heuristics for now: --- * Vertex-painted layer: opaque iff cell is material AND its bitmask --- resolves to slot 13 (full solid). An override that forces slot 13 --- also counts. Other slots have transparent regions so they --- can't be a ceiling. --- * Legacy tiles[] path: opaque iff the resolved atlas tile carries --- an explicit `opaque == true` flag. Atlases don't ship this flag --- today (deferred to atlas-baker E2 alpha-analysis), so this path --- defaults to non-opaque. Safe-conservative: extra draws, never --- missing draws. +-- v0.5.1 override-format helper: normalize a stored override entry to +-- {slot, rot, flip}. Disk-compact form is a bare integer slot_id (back- +-- wards-compat with 0.5.0d); object form `{slot, rot?, flip?}` ships +-- rotation+flip explicitly for direct-mode terrain-override placement +-- with non-canonical orientation. Returns nil for malformed input. +local function normalize_override_entry(entry) + if type(entry) == "number" then + return { slot = entry, rot = 0, flip = 0 } + end + if type(entry) == "table" and type(entry.slot) == "number" then + return { + slot = entry.slot, + rot = entry.rot or 0, + flip = entry.flip or 0, + } + end + return nil +end + +-- Predicate "is cell (x, y) opaque on layer_name". Used by the +-- opaque-ceiling cache. Reads the actual atlas tile.opaque flag set +-- by atlas-baker v0.2.0+ alpha-analysis (was previously a slot-13 +-- heuristic in 0.5.0e — replaced now that the real data is available). +-- +-- Pre-v0.2.0 atlases without the opaque flag set will report all- +-- not-opaque, harmlessly forcing the renderer to draw layers it could +-- have skipped. Safe-conservative. local function cell_is_opaque_on_layer(map, layer_name, x, y) local layer = map.layers[layer_name] if not layer then return false end if layer.vertices then - local override_slot = layer.overrides and layer.overrides[x .. ":" .. y] - if override_slot ~= nil then - return override_slot == 13 + -- Resolve the slot that would render at this cell (override + -- first, else bitmask), then look up the actual tile.opaque + -- flag in the layer's material atlas. + local atlas = layer.material and map.atlas_by_alias + and map.atlas_by_alias[layer.material] + if not atlas then return false end + local override_entry = layer.overrides and layer.overrides[x .. ":" .. y] + local slot + if override_entry ~= nil then + local norm = normalize_override_entry(override_entry) + if not norm then return false end + slot = norm.slot + else + if not cell_has_material(layer.vertices, map.size.w, map.size.h, x, y) then + return false + end + local bitmask = compute_cell_bitmask_v3(layer.vertices, map.size.w, map.size.h, x, y) + local rec = SLOT_LOOKUP[bitmask] + if not rec then return false end + slot = rec.slot end - if not cell_has_material(layer.vertices, map.size.w, map.size.h, x, y) then - return false - end - local bitmask = compute_cell_bitmask_v3(layer.vertices, map.size.w, map.size.h, x, y) - return bitmask == 0xFF + local tile_id = atlas_slot_index(atlas)[slot] + local tile = tile_id and atlas.tiles[tile_id] + return tile and tile.opaque == true or false end if layer.tiles then local gid = layer.tiles[y * map.size.w + x + 1] or 0 @@ -476,12 +507,77 @@ local function validate_map_table_v3(t, source) if layer_data.vertices ~= nil and type(layer_data.vertices) ~= "table" then error(string.format("maps.load: schema violation in %s: vertices must be array", lsrc)) end - if layer_data.overrides ~= nil and type(layer_data.overrides) ~= "table" then - error(string.format("maps.load: schema violation in %s: overrides must be table", lsrc)) + if layer_data.overrides ~= nil then + if type(layer_data.overrides) ~= "table" then + error(string.format("maps.load: schema violation in %s: overrides must be table", lsrc)) + end + -- v0.5.1: each override entry is either a bare int (compact + -- canonical-orientation form) or an object {slot, rot?, flip?} + -- for explicit-transform placement. Validate per-entry. + for k, v in pairs(layer_data.overrides) do + if type(v) == "number" then + if v ~= math.floor(v) or v < 0 or v > 13 then + error(string.format( + "maps.load: schema violation in %s.overrides[%s]: bare slot %s must be integer 0..13", + lsrc, tostring(k), tostring(v))) + end + elseif type(v) == "table" then + if type(v.slot) ~= "number" or v.slot ~= math.floor(v.slot) + or v.slot < 0 or v.slot > 13 then + error(string.format( + "maps.load: schema violation in %s.overrides[%s]: object must have slot integer 0..13", + lsrc, tostring(k))) + end + if v.rot ~= nil and (type(v.rot) ~= "number" or v.rot < 0 or v.rot > 3) then + error(string.format( + "maps.load: schema violation in %s.overrides[%s]: rot must be 0..3", + lsrc, tostring(k))) + end + if v.flip ~= nil and v.flip ~= 0 and v.flip ~= 1 then + error(string.format( + "maps.load: schema violation in %s.overrides[%s]: flip must be 0 or 1", + lsrc, tostring(k))) + end + else + error(string.format( + "maps.load: schema violation in %s.overrides[%s]: value must be integer slot or {slot, rot?, flip?} object", + lsrc, tostring(k))) + end + end + end + -- Reserved per-layer fields (design paper §8 + §14b + §15.1). + -- Accept-but-ignore today; future slices wire them into the + -- renderer / collision-resolver / movement-mode system. + -- Type-checks here catch malformed values early instead of + -- letting them survive as silent garbage. + if layer_data.base_color ~= nil and type(layer_data.base_color) ~= "string" then + error(string.format("maps.load: schema violation in %s: base_color must be string (#RRGGBB)", lsrc)) + end + if layer_data.tint_override ~= nil and type(layer_data.tint_override) ~= "table" then + error(string.format("maps.load: schema violation in %s: tint_override must be table", lsrc)) + end + if layer_data.collision_policy ~= nil and type(layer_data.collision_policy) ~= "table" then + error(string.format("maps.load: schema violation in %s: collision_policy must be table", lsrc)) + end + if layer_data.traversal_modes ~= nil and type(layer_data.traversal_modes) ~= "table" then + error(string.format("maps.load: schema violation in %s: traversal_modes must be table", lsrc)) + end + if layer_data.foundation_mode ~= nil and type(layer_data.foundation_mode) ~= "table" then + error(string.format("maps.load: schema violation in %s: foundation_mode must be array (per-cell 0/1)", lsrc)) end end end + -- Reserved map-level fields for §14c multi-z-level support. + -- Accept-but-ignore today; future lib-core.maps v0.6 wires them + -- into a recursive load+link + foundation-reveal-composite pipeline. + if t.z_level ~= nil and type(t.z_level) ~= "number" then + error(string.format("maps.load: schema violation in %s: z_level must be integer", source)) + end + if t.z_below ~= nil and type(t.z_below) ~= "string" then + error(string.format("maps.load: schema violation in %s: z_below must be string map-id reference", source)) + end + if t.roof ~= nil then if type(t.roof) ~= "table" then error(string.format("maps.load: %s: roof must be array", source)) @@ -790,16 +886,21 @@ local function draw_layer(m, layer_name) if ceiling and layer_z and ceiling[idx] > layer_z then goto continue -- higher opaque layer covers this cell end - local override_slot = overrides and overrides[x .. ":" .. y] - local has_material = override_slot ~= nil + local override_entry = overrides and overrides[x .. ":" .. y] + local has_material = override_entry ~= nil or cell_has_material(layer.vertices, sz.w, sz.h, x, y) if not has_material then goto continue end local slot_index, rot, flip - if override_slot ~= nil then - -- Forced slot: render in canonical orientation, no transform. - slot_index, rot, flip = override_slot, 0, 0 + if override_entry ~= nil then + -- v0.5.1: override entry can be bare int (canonical + -- orientation, backwards-compat) or {slot, rot, flip} + -- object for explicit transform placement. + local norm = normalize_override_entry(override_entry) + if norm then + slot_index, rot, flip = norm.slot, norm.rot, norm.flip + end else local bitmask = compute_cell_bitmask_v3(layer.vertices, sz.w, sz.h, x, y) local rec = SLOT_LOOKUP[bitmask] @@ -1192,10 +1293,14 @@ function M.set_cell_gid(layer_name, x, y, gid, map_id) map._opaque_ceiling = nil end --- v3 vertex-painted-layer override APIs (0.5.0d). Override forces a --- specific canonical slot (0..13) at a cell, bypassing the bitmask --- autotile result. Override implies cell-has-material regardless of --- the vertex-grid state. Stored as sparse map keyed "x:y" -> slot_id. +-- v3 vertex-painted-layer override APIs. +-- 0.5.0d: override forces a specific canonical slot (0..13) at a cell, +-- bypassing the bitmask autotile result. Override implies +-- cell-has-material regardless of the vertex-grid state. +-- 0.5.1: override entry can be bare integer slot_id (canonical orientation, +-- compact-form, backwards-compat) OR object {slot, rot?, flip?} +-- for explicit-transform direct-mode placement. +-- Stored as sparse map keyed "x:y" -> entry. local function require_v3_layer(map, layer_name, fn_name) if map.schema_version < 3 then @@ -1221,17 +1326,50 @@ local function check_coords(map, x, y, fn_name) end end -function M.set_override(layer_name, x, y, slot_id, map_id) +-- 0.5.1: accepts either bare slot_id (compact form) or table +-- {slot, rot?, flip?} (explicit transform). Auto-stores as compact form +-- when rot+flip are both 0, else stores as object — minimises disk diff +-- for the common canonical-orientation case. +function M.set_override(layer_name, x, y, slot_or_entry, map_id) local map = require_map(map_id) check_coords(map, x, y, "maps.set_override") local layer = require_v3_layer(map, layer_name, "maps.set_override") - if type(slot_id) ~= "number" or slot_id < 0 or slot_id > 13 - or slot_id ~= math.floor(slot_id) then - error(string.format("maps.set_override: slot_id %s must be integer 0..13", - tostring(slot_id))) + local stored + if type(slot_or_entry) == "number" then + if slot_or_entry < 0 or slot_or_entry > 13 + or slot_or_entry ~= math.floor(slot_or_entry) then + error(string.format("maps.set_override: slot %s must be integer 0..13", + tostring(slot_or_entry))) + end + stored = slot_or_entry + elseif type(slot_or_entry) == "table" then + local slot = slot_or_entry.slot + local rot = slot_or_entry.rot or 0 + local flip = slot_or_entry.flip or 0 + if type(slot) ~= "number" or slot < 0 or slot > 13 + or slot ~= math.floor(slot) then + error(string.format("maps.set_override: object.slot %s must be integer 0..13", + tostring(slot))) + end + if type(rot) ~= "number" or rot < 0 or rot > 3 or rot ~= math.floor(rot) then + error(string.format("maps.set_override: object.rot %s must be integer 0..3", + tostring(rot))) + end + if flip ~= 0 and flip ~= 1 then + error(string.format("maps.set_override: object.flip %s must be 0 or 1", + tostring(flip))) + end + if rot == 0 and flip == 0 then + stored = slot -- canonical orientation -> compact form + else + stored = { slot = slot, rot = rot, flip = flip } + end + else + error(string.format("maps.set_override: must be integer slot or {slot, rot?, flip?} object, got %s", + type(slot_or_entry))) end if layer.overrides == nil then layer.overrides = {} end - layer.overrides[x .. ":" .. y] = slot_id + layer.overrides[x .. ":" .. y] = stored map._dirty = true if map._layer_has_content then map._layer_has_content[layer_name] = true @@ -1250,12 +1388,18 @@ function M.clear_override(layer_name, x, y, map_id) end end +-- 0.5.1: returns normalized form {slot, rot, flip} regardless of how +-- the entry is stored on disk (bare-int compact OR object form). Returns +-- nil when no override is set. Callers that need the on-disk shape +-- should consult layer.overrides directly. function M.get_override(layer_name, x, y, map_id) local map = require_map(map_id) check_coords(map, x, y, "maps.get_override") local layer = require_v3_layer(map, layer_name, "maps.get_override") if layer.overrides == nil then return nil end - return layer.overrides[x .. ":" .. y] + local entry = layer.overrides[x .. ":" .. y] + if entry == nil then return nil end + return normalize_override_entry(entry) end function M.set_roof(x, y, value, map_id) diff --git a/manifest.lib b/manifest.lib index 55317cf..5f44935 100644 --- a/manifest.lib +++ b/manifest.lib @@ -1 +1 @@ -{"id":"lib-core.maps","version":"0.5.0","api_min":"0.1"} +{"id":"lib-core.maps","version":"0.5.1","api_min":"0.1"}