-- Scripts/anim/sampler.lua — read the tables scratchpad/anim_export.py emits. -- -- ROUND 7. Every other file in this directory is GENERATED: one row per kept -- arcade frame, per named MovieClip, straight out of the cabinet's own Lumen -- timeline (see ANIM_PIPELINE.md). The exporter guarantees that between two -- kept rows every field is LINEAR, so sampling is a plain lerp and nothing here -- has to know what curve the animator drew. -- -- local anim = require("anim/sampler") -- local board = anim.load("anim/folder_board") -- local tx, ty, sy, a = board:at("instance_board_center", f) -- f in arcade frames -- local f0 = board:label("select_off") -- label -> frame -- -- `f` may be fractional (the engine's clock is ms, not frames): pass -- `anim.frames(ms)` or do `ms * 0.06` yourself. Frames before the first row -- clamp to it and frames after the last row clamp to that, which is exactly what -- a Lumen timeline parked on its last frame does. -- -- `:at` returns the fields in the table's own `fields` order. A track that does -- not exist returns nothing, so a caller can `if not tx then return end` instead -- of crashing a screen over a renamed clip. local M = {} local Track = {} Track.__index = Track local Clip = {} Clip.__index = Clip local FPS_MS = 60.0 / 1000.0 -- ms -> arcade frames function M.frames(ms) return ms * FPS_MS end function M.ms(frames) return frames / FPS_MS end -- ROUND 53 (r53-anim-lumen-sweep): the module-level M.frames/M.ms assume 60 fps, which is -- true of every clip exported so far -- but each generated table carries its OWN `fps` -- field from the .nulm container, and R49's audit flagged that this file ignored it. A -- clip whose container says anything other than 60 must be sampled through these -- per-clip converters instead of M.frames/M.ms. function Clip:frames(ms) return ms * (self.fps or 60) / 1000.0 end function Clip:msof(frames) return frames * 1000.0 / (self.fps or 60) end -- binary search: the last row whose frame is <= f local function seek(rows, f) local lo, hi = 1, #rows if f <= rows[lo][1] then return lo end if f >= rows[hi][1] then return hi end while lo < hi do local mid = math.floor((lo + hi + 1) / 2) -- `hi = mid - 1`, NOT `hi = mid`: with hi == lo + 1 the upper-biased midpoint IS -- hi, so `hi = mid` never shrinks the window and the loop spins forever (it hung -- the check harness before this line was right; on the cabinet it would have -- frozen the screen). if rows[mid][1] <= f then lo = mid else hi = mid - 1 end end return lo end -- Lua 5.1 spells it `unpack`, 5.2+ `table.unpack`; the engine's sol2 build is 5.4 but the -- fallback costs nothing and an `and/or` chain here would be wrong (a nil first field -- would silently take the other branch). local tunpack = table.unpack if tunpack == nil then tunpack = unpack end function Clip:at(name, f) local rows = self.tracks[name] if not rows or #rows == 0 then return nil end local n = #rows local i = seek(rows, f) local a = rows[i] if i >= n or f <= a[1] then return tunpack(a, 2, #a) end local b = rows[i + 1] local u = (f - a[1]) / (b[1] - a[1]) local out = {} for k = 2, #a do out[k - 1] = a[k] + (b[k] - a[k]) * u end return tunpack(out, 1, #out) end -- one named field of one track, by the name the exporter wrote into `fields` function Clip:get(name, f, field) local idx = self.index[field] if not idx then return nil end local rows = self.tracks[name] if not rows or #rows == 0 then return nil end local n = #rows local i = seek(rows, f) local a = rows[i] if i >= n or f <= a[1] then return a[idx + 1] end local b = rows[i + 1] local u = (f - a[1]) / (b[1] - a[1]) return a[idx + 1] + (b[idx + 1] - a[idx + 1]) * u end function Clip:label(name) return self.labels and self.labels[name] end function Clip:has(name) return self.tracks[name] ~= nil end function Clip:first() return self.range[1] end function Clip:last() return self.range[2] end -- ROUND 8 — FAIL SOFT. A generated table is the churn-prone part of this pipeline: it is -- rewritten by a script, it can be renamed, and a screen must never go down because one of -- them is missing or malformed. `M.safe` therefore ALWAYS returns something with the Clip -- interface: the real clip, or this NULL clip, whose readers return nil and whose range is -- empty. Every call site in the skin already ends in `or ` (or -- tests the first return for nil), so a null clip degrades to the pre-pipeline behaviour -- rather than raising. -- -- ALSO A TRAP WORTH KEEPING: the engine indexes skin scripts FLAT, by file STEM -- (`src/libs/script.cpp` `index_scripts`), across every Scripts/ subfolder, first one wins, -- and `anim/` sorts before every screen folder. A table named `anim/result_crown.lua` -- therefore SHADOWED `result/result_crown.lua`, so `preload("ResultCrown", "result_crown")` -- loaded the data table, the `ResultCrown` global was never defined, and every frame of the -- result screen threw. A file in this directory MUST NOT share its stem with any screen -- script in this skin or its parent -- that one is now `anim/crown_mc.lua`. local NULL = {} NULL.__index = NULL function NULL:at() return nil end function NULL:get() return nil end function NULL:label() return nil end function NULL:has() return false end function NULL:first() return 0 end function NULL:last() return 0 end function NULL:frames(ms) return ms * FPS_MS end -- ROUND 53: keep NULL's interface complete function NULL:msof(frames) return frames / FPS_MS end M.NULL = setmetatable({}, NULL) -- never raises: returns the clip, or the NULL clip if the table is missing or malformed function M.safe(path) local ok, clip = pcall(M.load, path) if ok and clip and clip.tracks and clip.fields and clip.range then return clip end return M.NULL end local cache = {} -- `path` is a require() path, e.g. "anim/folder_board". function M.load(path) local c = cache[path] if c then return c end local data = require(path) data.index = {} for i, name in ipairs(data.fields) do data.index[name] = i end setmetatable(data, Clip) cache[path] = data return data end return M