voxel_shots_ab.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
-- Driver: the pixel-identity gate for performance work. -- -- Every optimization in this mod's performance pass claims the frame comes -- out the same. This driver is what makes that claim checkable rather than -- asserted: it renders a fixed set of scenes -- several maps, indoors and -- out, at every camera rung, in every display mode -- and writes one PNG -- per scene. Run it before a change and after it, hash the two directories, -- and any file whose hash moved is a scene the change altered. -- -- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_shots_ab.lua \ -- SHOT_DIR=<dir> AB_TAG=before lovec.exe . -- -- knobs (env): -- SHOT_DIR output directory (created if missing) (default "shots/ab") -- AB_TAG subdirectory under SHOT_DIR (default "before") -- AB_MODES display modes to sweep, comma list (default all four) -- -- DETERMINISM is the whole game here, because a shot that differs for a -- reason other than the change under test makes the gate useless: -- -- * the day/night clock is PINNED (an unpinned sky is a different sky -- every second, and it drives the sun angle and the shadow frustum); -- * the animated tile slots ride the engine's 60Hz counter, so every -- scene is reached after the SAME number of frames from the same -- starting state, and the water is at the same point in its roll; -- * levels are set through Pipelines.setLevel, never the hotkey, so the -- run cannot write the player's options; -- * encounters are stubbed off -- a wild battle would replace the scene -- the shot is named for. return function(game) local U = dofile("tests/drivers/util.lua") local Pipelines = require("src.render.Pipelines") local OverworldState = require("src.world.OverworldController") local ROOT = (os.getenv("SHOT_DIR") or "shots/ab") .. "/" .. (os.getenv("AB_TAG") or "before") local handle = game.mods.exports["DRAMATIC_SHAPE"] if not (handle and handle.lib) then print("[ab] DRAMATIC_SHAPE mod not loaded -- nothing to compare") return end local V = handle.lib local DayNight = V.require("DayNight") OverworldState.rollEncounter = function() return nil end -- NPCs roam on a random timer (src/world/NPC.lua), and LOVE's RNG is -- seeded differently every launch -- so two runs of this driver put the -- same townsfolk in different places and every shot with a person in it -- differs for a reason that has nothing to do with the change under -- test. Freeze them: `frozen` is the flag the NPC's own update already -- honours, and an NPC mid-step still finishes it, so the settle below -- lands on a still scene. They are still POSED and still drawn, so the -- billboard, its lean and its shadow are all still under test. local NPC = require("src.world.NPC") if not NPC.dramaticShapeAbFreeze then local inner = NPC.update function NPC:update(...) self.frozen = true return inner(self, ...) end NPC.dramaticShapeAbFreeze = true end pcall(love.math.setRandomSeed, 20260730) -- Freeze the tile-animation clock, on BOTH routes to it. -- -- TileRenderer.tick consumes WALL-CLOCK dt (so the water rolls at the -- same speed on a 60Hz and a 144Hz panel), which means the step a shot -- catches depends on how fast the machine got there rather than on -- anything the run did. Stubbing tick pins the counter the flat tile -- layer reads. -- -- The mod reads the SAME counter but through its own chain -- (TerrainAtlas.animFrame): TileRenderer.animFrame if the build exports -- one, else the local off tick's upvalues, else -- and this is the trap -- -- wall-clock time. A stubbed tick has no upvalues, so stubbing it -- ALONE knocks the mod onto the wall-clock fallback and makes the -- flowers drift between two otherwise identical runs. Exporting a -- constant animFrame takes the first branch and pins that route too. local TileRenderer = require("src.render.TileRenderer") TileRenderer.tick = function() end TileRenderer.animFrame = function() return 0 end -- The scenes. Chosen for what each one can BREAK, not for looks: -- ROUTE_1 open ground, grass billboards, a long view north -- -- the case a shadow-frustum or culling change moves -- VIRIDIAN_CITY buildings, window panes (the glass mask), signs -- PALLET_TOWN the seam with Route 1: neighbour meshes and ring -- VIRIDIAN_FOREST dense round-tree hulls, heavy occlusion -- REDS_HOUSE_1F indoors: no sky, no sun, authored figures -- PEWTER_CITY a second tileset with its own atlas bake local SCENES = { { id = "ROUTE_1", x = 10, y = 20, face = "up", label = "open" }, { id = "VIRIDIAN_CITY", x = 20, y = 26, face = "up", label = "town" }, { id = "PALLET_TOWN", x = 10, y = 2, face = "up", label = "seam" }, { id = "VIRIDIAN_FOREST", x = 16, y = 24, face = "up", label = "trees" }, { id = "REDS_HOUSE_1F", x = 4, y = 4, face = "up", label = "indoor" }, { id = "PEWTER_CITY", x = 16, y = 20, face = "down", label = "pewter" }, } -- OFF is in the list deliberately: a change that speeds the 3D path up -- must not have touched the flat one either. Then the three camera -- rungs, 75 last because it is the low camera this performance work is -- aimed at. -- -- FULL (rung 1) is NOT here, and cannot usefully be. It is a settings -- PRESET, not a render path: it sets tilt-shift to maximum, flattens the -- world curve, fits the zoom, switches 3D battles on -- and pins DAYTIME -- to SYNC and HOLDS it there (main.lua's applyFull / DayNight.forceSync), -- which overrides this driver's pinned clock and makes every shot after -- it depend on the wall clock. It also persists all of that, so one run's -- FULL changes the options the NEXT run starts from. What FULL renders is -- 35 degrees with the blur at 3, which rung 3 plus AB_TSHIFT=3 covers -- exactly. local RUNGS = {} for n in (os.getenv("AB_RUNGS") or "0,2,3,5"):gmatch("%d+") do RUNGS[#RUNGS + 1] = tonumber(n) end local TSHIFT = math.floor(tonumber(os.getenv("AB_TSHIFT")) or 0) -- AB_SHADOW=0 renders with the sun pass's contribution turned off -- (SHADOW_ALPHA 0 short-circuits the lookup in the scene shader). A -- bisection tool: when a set of shots will not reproduce, this says -- whether what is moving is in the shadow map or somewhere else. if os.getenv("AB_SHADOW") == "0" then V.require("Voxel3D").SHADOW_ALPHA = 0 end -- PaletteFX.MODES, minus the inverted novelties: `ogred` and `classic` -- are the SGB paths this mod bakes an atlas for, `gbc` is the shared -- default, and `redpp` is the one that rebakes an atlas PER MAP -- four -- genuinely different routes through TerrainAtlas. local MODES = {} for m in (os.getenv("AB_MODES") or "ogred,classic,gbc,redpp"):gmatch("[^,]+") do MODES[#MODES + 1] = m end -- Two times of day, because half the shader only runs in one of them: -- the window lamps, the moon disc and the night tint are all dark-only, -- and the glint sweep and the sun disc are day-only. local TIMES = { "day", "night" } local shots, missed = 0, 0 -- U.shot's own mkdir is the POSIX one, which cmd.exe does not -- understand, and a missing directory makes every capture vanish -- silently. Try both spellings once, up front. pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') -- A capture that always costs the SAME number of frames. -- -- U.shot spins up to 120 frames waiting for the capture to land, which -- is right for a screenshot and wrong for this: the animated tile slots -- (water rolling, flowers opening) ride the engine's frames-since-boot -- counter, so a scene reached after a different number of frames renders -- its water at a different point in the roll and the shot differs for a -- reason no change caused. A driver resume and a rendered frame are 1:1 -- here, so the capture lands on the next draw and a fixed budget is both -- enough and constant. local CAPTURE_FRAMES = 4 local ChunkMesher = V.require("ChunkMesher") local Voxel = V.require("VoxelState") local ShadowMap = V.require("ShadowMap") -- Wait for the scene to actually BE the scene the shot is named for. -- Two things are still in motion after a teleport, and both are timed in -- wall-clock seconds rather than frames, so "wait N frames" settles them -- by a different amount on every machine and every run: -- -- the build queue -- meshes are built on a per-frame time budget, so a -- slower run captures a half-built neighbour; -- the camera tween -- Voxel.t runs on dt over TWEEN_TIME, so a shot -- taken before it lands is at some arbitrary intermediate pitch. -- -- Both are waited on by their own completion flag, then a short fixed -- settle. The variable wait is harmless now that the animation clock is -- frozen above -- otherwise it would move the water instead. -- and the CAMERA, which is the subtle one. It eases toward the player -- over wall-clock dt, so after a fixed wait it has covered a distance -- that depends on how fast the machine ran -- and the sun pass is only -- redrawn when the camera crosses a quarter-world-pixel (VoxelScene's -- shadow signature), so a frame caught mid-ease carries a shadow map -- fitted for a slightly different camera than the one it is drawn with. -- That is a real and deliberate tolerance in the mod, but it makes the -- gate compare two arbitrary points inside it. Waiting for the camera -- to stop moving entirely puts every shot at the same steady state. local function cameraStill() local o = game.overworld local c = o and o.camera if not c then return true end local lx, ly, held = nil, nil, 0 for _ = 1, 300 do if c.x == lx and c.y == ly then held = held + 1 if held >= 10 then return true end else held = 0 lx, ly = c.x, c.y end U.wait(1) end return false end local function settleBuild() for _ = 1, 900 do if ChunkMesher.pending() == 0 then break end U.wait(1) end for _ = 1, 300 do if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end U.wait(1) end cameraStill() -- and then force one final sun pass at the settled camera. The map is -- only redrawn when the camera crosses a quarter world pixel, so a -- still camera holds whatever was drawn at the moment it last did -- -- correct to within that tolerance, but fitted from a position that -- depends on where the easing happened to be, which differs by a few -- hundredths of a pixel between runs and moves every shadow edge by a -- shade or two. Forgetting the stamp redraws from the state the shot -- is actually taken in, and two runs then agree exactly. -- guarded so this driver can also be pointed at a build that predates -- the seam, which is exactly what capturing a "before" reference means if ShadowMap.forget then ShadowMap.forget() end U.wait(20) end -- AB_TRACE=1 prints the state each shot was taken in. When two runs of -- this driver disagree, this is what says which input moved. local TRACE = os.getenv("AB_TRACE") == "1" local function trace(name) if not TRACE then return end local o = game.overworld local e = ShadowMap.extent or {} print(("[ab] %-28s cam=(%.4f,%.4f) player=(%.3f,%.3f) res=%d extent=(%.3f,%.3f,%.3f) KX=%.6f KZ=%.6f angle=%.6f pend=%d") :format(name, o and o.camera and o.camera.x or -1, o and o.camera and o.camera.y or -1, o and o.player and o.player.px or -1, o and o.player and o.player.py or -1, ShadowMap.res or 0, e[1] or 0, e[2] or 0, e[3] or 0, ShadowMap.KX or 0, ShadowMap.KZ or 0, Voxel.angle or 0, ChunkMesher.pending())) end -- AB_SHADOWDUMP=1 also writes the packed depth map itself, into the save -- directory. When the scene differs but every input to the sun pass is -- identical, the map is the only place left to look. local DUMP = os.getenv("AB_SHADOWDUMP") == "1" local function dumpShadow(name) if not DUMP then return end local tex = ShadowMap.texture() if not (tex and tex.newImageData) then return end pcall(function() love.filesystem.createDirectory("ab_shadow") tex:newImageData():encode("png", "ab_shadow/" .. name .. ".png") end) end local function capture(name) trace(name) dumpShadow(name) local path = ("%s/%s.png"):format(ROOT, name) game.capturePath = path U.wait(CAPTURE_FRAMES) local f = io.open(path, "rb") if f then f:close() shots = shots + 1 else missed = missed + 1 print("[ab] capture did not reach disk: " .. path) end end local PaletteFX = require("src.render.PaletteFX") local function setMode(mode) local known = false for _, m in ipairs(PaletteFX.MODES) do if m == mode then known = true break end end if not known then return false end return (pcall(PaletteFX.setMode, mode)) end -- A fixed zoom, so the view size every shot is composed at is the same -- one. Zoom is persisted, so without this a session that ever ran the -- FULL preset (which fits the zoom to the window) leaves a different -- view size behind for every later run. local Zoom = require("src.render.Zoom") pcall(function() game.save.options.zoom = 1 Zoom.applyOptions(game.save.options) end) for _, mode in ipairs(MODES) do if setMode(mode) then for _, when in ipairs(TIMES) do DayNight.setting:sync(when) for _, s in ipairs(SCENES) do for _, rung in ipairs(RUNGS) do U.teleport(game, s.id, s.x, s.y, s.face) Pipelines.setLevel("voxel", rung) -- pinned AFTER the voxel rung, because FULL is a preset that -- reaches over and sets this row itself (main.lua's applyFull) -- and persists it -- so without this, one run's FULL leaks a -- blur level into the NEXT run's options.lua and every shot -- differs for a reason no code change caused. Sharp by -- default: a gaussian smears a one-pixel geometry difference -- across the whole frame, which is exactly what a gate meant -- to localise differences must not do. AB_TSHIFT=3 runs the -- blur path deliberately. Pipelines.setLevel("tiltshift", TSHIFT) -- Wait for the build queue to drain rather than for a fixed -- number of frames. Meshes are built on a per-frame time -- budget, so how much of a map exists after N frames is a -- property of the MACHINE -- a slower run captures a -- half-built neighbour and the shot differs for no reason the -- change caused. Draining first, then settling a fixed 40 -- frames for the camera tween, makes the scene the same -- scene everywhere. (Safe now that the animation clock above -- is frozen: a variable wait no longer moves the water.) settleBuild() capture(("%s_%s_%s_v%d"):format(mode, when, s.label, rung)) end end end else print("[ab] display mode " .. mode .. " unavailable, skipped") end end print(("[ab] %d shots into %s (%d failed to reach disk)") :format(shots, ROOT, missed)) end