maps v0.5.1 — opaque-flag + manifest fields + override format

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) <noreply@anthropic.com>
This commit is contained in:
calic
2026-05-28 23:09:24 +02:00
parent f8994c7ffa
commit d0f4d17b65
2 changed files with 182 additions and 38 deletions

218
init.lua
View File

@@ -295,30 +295,61 @@ local function atlas_slot_index(atlas)
return atlas._slot_index return atlas._slot_index
end end
-- 0.5.0e: predicate "is cell (x, y) opaque on layer_name". Used by the -- v0.5.1 override-format helper: normalize a stored override entry to
-- opaque-ceiling cache. Heuristics for now: -- {slot, rot, flip}. Disk-compact form is a bare integer slot_id (back-
-- * Vertex-painted layer: opaque iff cell is material AND its bitmask -- wards-compat with 0.5.0d); object form `{slot, rot?, flip?}` ships
-- resolves to slot 13 (full solid). An override that forces slot 13 -- rotation+flip explicitly for direct-mode terrain-override placement
-- also counts. Other slots have transparent regions so they -- with non-canonical orientation. Returns nil for malformed input.
-- can't be a ceiling. local function normalize_override_entry(entry)
-- * Legacy tiles[] path: opaque iff the resolved atlas tile carries if type(entry) == "number" then
-- an explicit `opaque == true` flag. Atlases don't ship this flag return { slot = entry, rot = 0, flip = 0 }
-- today (deferred to atlas-baker E2 alpha-analysis), so this path end
-- defaults to non-opaque. Safe-conservative: extra draws, never if type(entry) == "table" and type(entry.slot) == "number" then
-- missing draws. 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 function cell_is_opaque_on_layer(map, layer_name, x, y)
local layer = map.layers[layer_name] local layer = map.layers[layer_name]
if not layer then return false end if not layer then return false end
if layer.vertices then if layer.vertices then
local override_slot = layer.overrides and layer.overrides[x .. ":" .. y] -- Resolve the slot that would render at this cell (override
if override_slot ~= nil then -- first, else bitmask), then look up the actual tile.opaque
return override_slot == 13 -- 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 end
if not cell_has_material(layer.vertices, map.size.w, map.size.h, x, y) then local tile_id = atlas_slot_index(atlas)[slot]
return false local tile = tile_id and atlas.tiles[tile_id]
end return tile and tile.opaque == true or false
local bitmask = compute_cell_bitmask_v3(layer.vertices, map.size.w, map.size.h, x, y)
return bitmask == 0xFF
end end
if layer.tiles then if layer.tiles then
local gid = layer.tiles[y * map.size.w + x + 1] or 0 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 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)) error(string.format("maps.load: schema violation in %s: vertices must be array", lsrc))
end end
if layer_data.overrides ~= nil and type(layer_data.overrides) ~= "table" then if layer_data.overrides ~= nil then
error(string.format("maps.load: schema violation in %s: overrides must be table", lsrc)) 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 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 t.roof ~= nil then
if type(t.roof) ~= "table" then if type(t.roof) ~= "table" then
error(string.format("maps.load: %s: roof must be array", source)) 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 if ceiling and layer_z and ceiling[idx] > layer_z then
goto continue -- higher opaque layer covers this cell goto continue -- higher opaque layer covers this cell
end end
local override_slot = overrides and overrides[x .. ":" .. y] local override_entry = overrides and overrides[x .. ":" .. y]
local has_material = override_slot ~= nil local has_material = override_entry ~= nil
or cell_has_material(layer.vertices, sz.w, sz.h, x, y) or cell_has_material(layer.vertices, sz.w, sz.h, x, y)
if not has_material then if not has_material then
goto continue goto continue
end end
local slot_index, rot, flip local slot_index, rot, flip
if override_slot ~= nil then if override_entry ~= nil then
-- Forced slot: render in canonical orientation, no transform. -- v0.5.1: override entry can be bare int (canonical
slot_index, rot, flip = override_slot, 0, 0 -- 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 else
local bitmask = compute_cell_bitmask_v3(layer.vertices, sz.w, sz.h, x, y) local bitmask = compute_cell_bitmask_v3(layer.vertices, sz.w, sz.h, x, y)
local rec = SLOT_LOOKUP[bitmask] 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 map._opaque_ceiling = nil
end end
-- v3 vertex-painted-layer override APIs (0.5.0d). Override forces a -- v3 vertex-painted-layer override APIs.
-- specific canonical slot (0..13) at a cell, bypassing the bitmask -- 0.5.0d: override forces a specific canonical slot (0..13) at a cell,
-- autotile result. Override implies cell-has-material regardless of -- bypassing the bitmask autotile result. Override implies
-- the vertex-grid state. Stored as sparse map keyed "x:y" -> slot_id. -- 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) local function require_v3_layer(map, layer_name, fn_name)
if map.schema_version < 3 then if map.schema_version < 3 then
@@ -1221,17 +1326,50 @@ local function check_coords(map, x, y, fn_name)
end end
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) local map = require_map(map_id)
check_coords(map, x, y, "maps.set_override") check_coords(map, x, y, "maps.set_override")
local layer = require_v3_layer(map, layer_name, "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 local stored
or slot_id ~= math.floor(slot_id) then if type(slot_or_entry) == "number" then
error(string.format("maps.set_override: slot_id %s must be integer 0..13", if slot_or_entry < 0 or slot_or_entry > 13
tostring(slot_id))) 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 end
if layer.overrides == nil then layer.overrides = {} end if layer.overrides == nil then layer.overrides = {} end
layer.overrides[x .. ":" .. y] = slot_id layer.overrides[x .. ":" .. y] = stored
map._dirty = true map._dirty = true
if map._layer_has_content then if map._layer_has_content then
map._layer_has_content[layer_name] = true map._layer_has_content[layer_name] = true
@@ -1250,12 +1388,18 @@ function M.clear_override(layer_name, x, y, map_id)
end end
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) function M.get_override(layer_name, x, y, map_id)
local map = require_map(map_id) local map = require_map(map_id)
check_coords(map, x, y, "maps.get_override") check_coords(map, x, y, "maps.get_override")
local layer = require_v3_layer(map, layer_name, "maps.get_override") local layer = require_v3_layer(map, layer_name, "maps.get_override")
if layer.overrides == nil then return nil end 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 end
function M.set_roof(x, y, value, map_id) function M.set_roof(x, y, value, map_id)

View File

@@ -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"}