Repository navigation
Expand file tree
/
Copy pathmain.lua
More file actions
2484 lines (2341 loc) · 113 KB
/
Copy pathmain.lua
File metadata and controls
2484 lines (2341 loc) · 113 KB
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
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- Gen1AutoSave
--
-- Saves on its own so manual saving becomes optional, and stays out of the
-- built-in save sync's way while doing it:
--
-- * a save that comes due while you are walking is never written into the
-- stride: it waits for a moment you could not move in anyway -- a warp's
-- black screen, a battle starting or ending, a text box while somebody
-- talks, any menu -- or for you to really stop, which is three unbroken
-- seconds and not the frame between two strides
-- * a route seam is not a door. Walking from one route into the next is
-- seamless -- no screen, and the player mid-stride the whole way across --
-- so it neither writes a save nor asks for one
-- * and the sync cycle the write wakes, which is the expensive half and
-- lands seconds later on network time, waits for the NEXT one of those
-- rather than for the same one: two errands, two windows
-- * the collector is NUDGED after a write, not run. A save allocates about
-- 2.8MB; this used to hand the collector 48MB of credit to pay that off,
-- which is a whole cycle in one frame and was itself the stutter
-- * the timer runs during battles and menus, not only while you stand still
-- on the map -- the write itself still waits for a settled overworld
-- * nothing happened since the last write => no write, so idling on the map
-- never bumps the save revision or wakes an upload
-- * never writes while sync is mid-transfer or holding an unresolved
-- conflict OVER THIS SAVE; the save is retried once sync settles
-- * a "conflict" whose two sides share a sessionStart is this device's own
-- lost upload, not a second player: answered with keep-this-device rather
-- than put to the player, who has nothing to decide
-- * one floor between writes, so a row of doors can't hammer the file
-- * picking QUIT offers the save in the confirm box, and the quit waits
-- for the write -- and for the upload it starts -- before it leaves
--
-- Game:writeSave() already tells the sync engine it happened (5s upload
-- debounce), so nothing here has to ask for a sync -- only choose how often
-- to let one happen, and pay for what a save leaves behind at the save:
--
-- * the upload goes with the save and goes at once: a save is finished when
-- it reaches the account, not when it reaches the disk, and the five
-- second debounce would only move the request off the black screen it
-- could have left from
-- * finish the collector's cycle in the frame that wrote the file, and in
-- the frame a sync cycle ended, rather than on the route after either
-- `unpack` moved between Lua versions, and this mod runs under both. LOVE is
-- LuaJIT, which is 5.1: the GLOBAL `unpack` is the one that exists there and
-- `table.unpack` is nil (the sandbox copies the host's `table` faithfully,
-- Sandbox.lua:154, so there is nothing to fill it in). Standalone Lua 5.4 --
-- which is what tests/ runs under -- is the other way round. Resolved once,
-- here, because the expression that used to do it inline was wrong in a way
-- only the game could show. See the QUIT wrap below.
local unpackArgs = unpack or table.unpack
return function(mod)
local MIN_GAP = 20 -- seconds between any two autosaves
local SYNC_RETRY = 2.0 -- re-check a busy sync this often
local STILL_FOR = 3.0 -- unbroken seconds of standing still on the route
local SETTLE_GRACE = 1.5 -- how long a menu or a conversation counts for
-- after it closes
local HOLD_GRACE = 15 -- a conflict has to stand this long to be said
local HEAL_TRIES = 3 -- give up healing one key after this many goes
local NOTIFY_TIME = 1.6
local GC_STEP = 512 -- collector work per step, in KB of allocation
local GC_STEPS = 2 -- and no more than this, so the nudge can never
-- become a whole collection cycle
local BOX_W, BOX_H = 20, 3
local ICON_W, ICON_H = 7, 3
-- One 8x8 tile in from the playfield's corner, the same inset the engine
-- gives its own furniture. Both indicators use it, so the ball and the
-- SAVED panel land on the same spot. At 2 and 4 GB pixels they sat all but
-- touching the corner, which on a screen the picture fills is close enough
-- to the edge to look like a mistake -- and on a phone close enough to go
-- under a rounded corner.
local HUD_MARGIN = 8
local MESSAGE_TEXT = "Game saved."
local ICON_TEXT = "SAVED"
local HELD_MESSAGE = "Autosave paused."
local HELD_ICON = "PAUSED"
-- The QUIT confirm, in place of the engine's "RETURN TO MAIN MENU?": the
-- box has to say what YES is about to do, or the save is a surprise. Both
-- lines fit the 18 columns a text box gives them.
local QUIT_PROMPT = "SAVE AND RETURN\nTO MAIN MENU?"
local QUIT_SAVING = "Now saving..."
local QUIT_WAIT = 15 -- seconds the quit will hold for save + upload
local state = {
clock = 0,
elapsed = 0,
lastWriteAt = -math.huge,
dirty = false,
due = false,
inBattle = false,
-- Set from battle.ended until the battle's own onFinish has returned --
-- the window in which the fight is over and its OUTCOME is not written
-- yet. See requestAfterOutcome.
outcomePending = false,
outcomeClear = 0,
saving = false,
syncWaitUntil = 0,
syncWasBusy = false,
gcOwed = false,
stillFor = 0,
settledAt = nil,
wasHeld = false,
notify = 0,
heldTold = false,
heldSince = nil,
healKey = nil,
healTries = 0,
game = nil,
}
mod.options:define({
{
key = "enabled",
type = "toggle",
label = "AUTO SAVE",
default = true,
help = "Save progress automatically while you play.",
},
{
-- OFF by default. Battles, catches, evolutions, hatches, trades,
-- blackouts and every new map already cover ordinary play, and picking
-- QUIT covers the way out; a clock on top of that mostly buys writes
-- that had nothing new to write. Still here for anyone who wants one.
key = "interval",
type = "choice",
label = "INTERVAL",
default = 0,
choices = {
{ "OFF", 0 },
{ "1 MIN", 60 },
{ "2 MIN", 120 },
{ "5 MIN", 300 },
{ "10 MIN", 600 },
{ "15 MIN", 900 },
},
help = "An extra save every so much play time. Off: events cover it.",
visible_if = { key = "enabled", equals = true },
},
{
key = "events",
type = "toggle",
label = "AFTER EVENTS",
default = true,
help = "Also save after battles, catches, evolutions and new areas.",
visible_if = { key = "enabled", equals = true },
},
{
key = "onquit",
type = "toggle",
label = "ON QUIT",
default = true,
help = "Offer to save in the QUIT confirm, and wait for it.",
visible_if = { key = "enabled", equals = true },
},
{
key = "heal",
type = "toggle",
label = "HEAL CONFLICTS",
default = true,
help = "Answer a sync conflict with your own older upload on both sides.",
visible_if = { key = "enabled", equals = true },
},
{
key = "on_load",
type = "toggle",
label = "SAVE ON LOADS",
default = true,
help = "Save in a moment you could not move in anyway.",
visible_if = { key = "enabled", equals = true },
},
{
key = "quiet_sync",
type = "toggle",
label = "QUIET SYNC",
default = true,
help = "Hold a sync cycle out of the frames you are walking through.",
visible_if = { key = "enabled", equals = true },
},
{
key = "notify",
type = "choice",
label = "INDICATOR",
default = "ball",
choices = {
{ "OFF", "off" },
{ "POKE BALL", "ball" },
{ "SAVED TEXT", "icon" },
{ "TEXT BOX", "box" },
},
help = "How an autosave announces itself.",
visible_if = { key = "enabled", equals = true },
},
{
key = "backups",
type = "toggle",
label = "SAVE BACKUPS",
default = false,
help = "Rollback copies in the START menu. Roughly triples a save's cost.",
visible_if = { key = "enabled", equals = true },
},
{
key = "keep",
type = "choice",
label = "BACKUPS KEPT",
default = 5,
choices = {
{ "3", 3 },
{ "5", 5 },
{ "10", 10 },
{ "20", 20 },
},
help = "How many rollback copies to keep before the oldest is dropped.",
visible_if = { key = "backups", equals = true },
},
})
-- defined further down, next to the storage helpers it needs
local captureBackup
-- ---------- options
local function on()
return mod.options:get("enabled") == true
end
local function intervalSeconds()
local value = mod.options:get("interval")
if type(value) ~= "number" then return 0 end
return value
end
-- ---------- when a write is allowed
-- Same settled-overworld rule the engine uses for its own snapshots: no
-- movement, no script, no transition, and the overworld actually on top.
-- ---------- THE WORLD, ASKED THE SAME QUESTIONS ON BOTH GAMES
--
-- Everything below this block is one of six questions about the map: is
-- there one, is the player mid-step, is a direction held, has a script got
-- the controls, is a fade running, is a screen standing over it. Each has
-- an answer in both games and the two games spell every one of them
-- differently, so the spelling is done once, here, and nothing further down
-- knows which game it is on.
--
-- It was NOT done at all before 0.32.24, and the cost was the whole mod.
-- Every one of those questions was asked as `game.overworld`, which is
-- Red's OverworldState singleton and does not exist on Gold -- Game2 holds
-- its world at `game.world` and it is not a stack state at all. So on a
-- Gold boot `writeWindow` opened with `if not (ow and ow.player) then
-- return false end` and returned false on every frame of every playthrough,
-- `writeUnderCover` returned before it looked at anything, and AUTO SAVE
-- never wrote a file in its life while reading ON in the menu.
--
-- (The `src.core.Game` facade does alias `Game.overworld` to `Game2.world`
-- for a mod that requires the module -- docs/mod-api-gen2-compat.md. This
-- mod never requires it: its `game` is the live instance a hook hands it,
-- which is the real Game2 and carries no alias. Reaching for the facade to
-- get one would be asking for an engine_internals permission to rename a
-- field this file can read either way.)
--
-- The four differences, and where each is written down in the engine:
--
-- the world `game.overworld` / `game.world`
-- free roam the overworld IS the top state / the stack is EMPTY
-- (src/core/Game2.lua:pipelineGate says it outright)
-- a fade `ow.transitioning` / `ow.teleportOut` on Red; on Gold
-- a `mapSetup` chain with `fade` and `fadeLevel` under
-- it (World:runMapSetup, World:battleReturnFade), which
-- is also why the veil questions below grew a second arm
-- the controls `ow.runner` + `ow.scriptMoves` / `World:busy`'s parts
local IS_GEN2 = (function()
local ok, GameVersion = pcall(require, "src.core.GameVersion")
if not ok or type(GameVersion) ~= "table" then return false end
if type(GameVersion.generation) ~= "function" then return false end
local okCall, generation = pcall(GameVersion.generation)
return okCall and generation == 2
end)()
-- The map object with a player standing on it, or nil. Both names are read
-- rather than one being picked by IS_GEN2, because a build that answers
-- neither -- a title screen, the mod manager -- has to come out nil on both
-- games and does either way.
local function worldOf(game)
if not game then return nil end
return game.overworld or game.world
end
-- Is a fade the WORLD ITSELF owns running? Not the same question as "is
-- there a veil on the screen" -- that is veilStepping / fullyVeiled below,
-- which also look at the stack -- but the narrower one the checkpoint half
-- asks: has the player just walked into a door.
local function worldFading(ow)
if not ow then return false end
if IS_GEN2 then
-- A warp, a door, a teleport, the ride back out of a battle and a
-- scripted FadeOutToWhite are all this pair; `fade` alone is the
-- scripted one, which has no chain (World:screenFade).
return (ow.mapSetup or ow.fade) ~= nil
end
return (ow.transitioning or ow.teleportOut) and true or false
end
-- Has something other than the player got the controls?
local function scriptRunning(ow)
if not ow then return false end
if IS_GEN2 then
-- World:busy() minus its `mapSetup` arm, which worldFading owns above.
-- The split matters in exactly one place and it is the important one:
-- `loadScreenWrite` refuses a running script and is the path that
-- writes UNDER a warp's black screen, so folding the fade in here would
-- refuse the one window this mod most wants.
if type(ow.scriptRunning) == "function" then
local okCall, running = pcall(ow.scriptRunning, ow)
if okCall and running then return true end
end
-- The rest of World:busy: a text box, a choice box, an applymovement,
-- the rod cast, the tree shake, a field move's tail, FlyToAnim and the
-- skyfall. All of them are blocking script commands on the cart, so
-- all of them are the player not having the controls.
return (ow.textbox or ow.choicebox or ow.moveState or ow.fishing
or ow.headbutt or ow.fieldMove or ow.flyAnim or ow.skyfall)
~= nil
end
if ow.runner and ow.runner.isRunning and ow.runner:isRunning() then
return true
end
return #(ow.scriptMoves or {}) > 0
end
-- Is the player standing on the map with nothing over them? Red answers
-- it with the overworld being the top state; Gold's world is not a state,
-- so free roam there is an EMPTY stack -- which is how Game2:pipelineGate
-- puts it too, and it is the same fact `screenOver` reads from the other
-- side.
local function freeRoam(game)
local top = game and game.stack and game.stack.top and game.stack:top()
if IS_GEN2 then return top == nil end
return top ~= nil and top == game.overworld
end
-- The map holding the player for something that is not a script: Red's
-- trainer-spotted `!` and its emote bubbles. Gold has no pair of flags for
-- it because it needs none -- the `!` is an applymovement inside a script
-- there, so `scriptRunning` above already carries it.
local function worldHolding(ow)
if not ow or IS_GEN2 then return false end
return (ow.engaging or ow.emote) and true or false
end
-- Any direction held, which is the engine's own hJoyHeld & PAD_CTRL_PAD
-- question (OverworldState:dirHeld). Asked through the overworld when it
-- can answer, and off the raw input when it cannot, so an engine older than
-- that method still gets a real answer rather than a permissive one.
local function walking(game, ow)
if ow and type(ow.dirHeld) == "function" then
local ok, held = pcall(ow.dirHeld, ow)
if ok then return held == true end
end
-- Gold keeps the same answer as a field rather than a method: `heldDir`
-- is refreshed from real input by World:pollInput and is the latch its
-- own ice slide reads (src/world/gen2/World.lua:10473).
if IS_GEN2 and ow and ow.heldDir ~= nil then return true end
local input = game and game.input
if input and type(input.isDown) == "function" then
for _, dir in ipairs({ "up", "down", "left", "right" }) do
local ok, down = pcall(input.isDown, input, dir)
if ok and down then return true end
end
end
return false
end
-- Is the game holding the player still?
--
-- A screen over the overworld is the answer to a lot of this at once: a
-- text box while an NPC is talking, the START menu, the bag, a mart, a PC,
-- a Center's heal. In every one of them the player COULD not move if they
-- wanted to, and the overworld behind is a still picture. Those are the
-- moments this mod wants and it does not have to name them one by one.
local function screenOver(game)
local ow = worldOf(game)
if not ow then return false end
local top = game.stack and game.stack.top and game.stack:top()
-- Gold's world is not a stack state, so ANY state on the stack is a
-- screen standing over it and an empty stack is free roam. Asking
-- `top ~= ow` there would have answered "a screen is up" on every frame
-- of the map and "nothing is up" inside every menu -- both backwards.
if IS_GEN2 then return top ~= nil end
return top ~= nil and top ~= ow
end
-- ------- a veil that is STEPPING is an animation, wherever it lives
--
-- quietFrame below already knows this about the overworld's own fades:
-- "A FADE IS AN ANIMATION", and `ow.transitioning or ow.teleportOut` is
-- that said in the vocabulary the overworld has. But the overworld is not
-- where most fades live. The end of a battle is a STACK STATE with a veil
-- of its own -- the engine's white `Transition.battleReturn`, and Gen1WildUI's
-- BLACK OUTRO on top of it -- and the overworld's flags are false the whole
-- way through both. So `screenOver` called those frames quiet, the sync
-- hold came off, and the plan the write had just woken landed in the middle
-- of the fade out of a battle. Every battle, and it is the one fade the
-- player watches all the way through.
--
-- A state says so itself: `alpha()` is the strength of its own veil. Full
-- means the picture cannot change and a long frame costs nothing; anything
-- BETWEEN means the veil is on its way somewhere and every frame of it is a
-- frame that was supposed to move. So a partial veil is exactly as busy as
-- a walk, and is answered the same way.
--
-- Not "a transition is up". At alpha 1 a transition is the best frame in
-- the game to spend, which is what fullyVeiled is for; refusing those would
-- give back the door freeze this mod spent three versions removing.
--
-- And on Gold the veil is not a state either. A door, a warp and the ride
-- back out of a battle are all `World:runMapSetup`, whose ramp is
-- `world.fade` with `world.fadeLevel` from 0 to 1 under it -- so the arm
-- below is the same question asked of the world instead of the stack. Both
-- are asked, in that order, because a mod's own fade IS a state on either
-- game and has the first word about what is on the screen.
local function veilStepping(game)
local top = game and game.stack and game.stack.top and game.stack:top()
if type(top) == "table" and type(top.alpha) == "function" then
local ok, a = pcall(top.alpha, top)
if ok and type(a) == "number" and a > 0 and a < 1 then return true end
end
if IS_GEN2 then
local ow = worldOf(game)
local level = ow and ow.fade and tonumber(ow.fadeLevel)
if level and level > 0 and level < 1 then return true end
end
return false
end
-- Is the SCREEN moving -- would a dropped frame be seen on this one?
--
-- Not the same question as whether a save may be written, and the two used
-- to share an answer. A battle is a fine frame to spend and a terrible one
-- to save in; a text box is fine for both. This one is only ever asked
-- about cost, so it says yes to everything the player is not walking
-- through.
--
-- Standing still is not `moving == false`. That flag drops for the single
-- frame between two strides, so somebody walking a route without stopping
-- satisfies it several times a second, and that gap is exactly where a
-- dropped frame is seen because the screen is scrolling on either side of
-- it. Nor is it one frame of stillness: letting go of the pad to change
-- direction is not a pause, it is part of walking. A real stop is
-- STILL_FOR seconds of one, or the moment a menu or a conversation just
-- ended and the player has not started moving again.
local function quietFrame(game)
local ow = worldOf(game)
-- No overworld at all: a title screen, the mod manager, a save select.
if not (ow and ow.player) then return true end
-- A FADE IS AN ANIMATION. This used to say the opposite -- a transition
-- was the freest frame there was, because nothing of the map is on screen
-- -- and that was wrong twice over. The warp fade is thirty-two logic
-- steps of a palette walking down to black, so freezing for a fifth of a
-- second in the middle of it is a stall you can see; and the steps are
-- counted per logic step, so the oversized frame afterwards is paid back
-- as a burst that runs the rest of the fade before anything is drawn.
-- Between them, walking through a door came out as a pop.
--
-- Ahead of screenOver, because the transition IS a screen over the
-- overworld and would have been called quiet by the line below.
if worldFading(ow) then return false end
-- And the same for a fade that is a state rather than a flag: the end of
-- a battle is the one every player watches, and screenOver called it the
-- quietest frame in the game. See veilStepping.
if veilStepping(game) then return false end
if screenOver(game) then return true end
if ow.player.moving then return false end
if walking(game, ow) then return false end
if scriptRunning(ow) then return false end
if state.stillFor >= STILL_FOR then return true end
if mod.options:get("on_load") ~= false then return false end
return state.settledAt ~= nil
and state.clock - state.settledAt <= SETTLE_GRACE
end
-- May a save be WRITTEN on this frame?
--
-- Everything quietFrame wants, and two things it does not care about:
--
-- * not in a battle. Gen 1 has no save inside one and neither has this:
-- the file would record the overworld the battle started from while the
-- player is somewhere else entirely in their head. The end of the
-- battle is a window of its own and is a better one.
-- * not part-way through a script. A script that has set some of its
-- flags and not the rest is not a state to write down; a save taken
-- there can put a player back into a half-finished cutscene. The
-- moment it ENDS is a window -- SETTLE_GRACE below -- and by then the
-- script is done and the player is standing where it left them.
--
-- AND A MENU IS NOT A WINDOW.
--
-- It used to be the widest one this had: anything over the overworld -- the
-- START menu, the bag, the party, a PC, a mart, a Centre's heal, a text box
-- while somebody talks -- on the reasoning that the player COULD not move
-- and the map behind is a still picture, so a dropped frame there is a
-- frame nobody sees.
--
-- Nobody sees it. They feel it. A menu is not a pause in the playing, it
-- is the part of the playing with the most presses per second in it:
-- choosing an item, swapping two moves, walking a cursor down a box list.
-- A frame lost there is an input lost there, which is worse than a frame
-- lost on a route -- a stutter mid-stride is ugly, a swallowed A press is
-- the game not listening. "The screen is not moving" was the wrong
-- question; the right one is whether the player is in the middle of doing
-- something, and in a menu they always are.
--
-- AND NEITHER IS THE MOMENT ONE CLOSES.
--
-- SETTLE_GRACE was the other half of that reasoning: the menu is gone, the
-- conversation has ended, the player is standing exactly where the game
-- left them and has not moved yet, so take it now. What that describes is
-- the frame a player has been WAITING for. They closed the box because
-- they wanted to go, and the write lands a beat after they are handed
-- control -- so the hitch is not in the pause, it is in the first stride
-- out of it. Reported exactly that way: a little late after finishing an
-- interaction, and felt on running away.
--
-- With SAVE ON LOADS on there is no reason to take it: a screen the player
-- cannot see is coming -- the next door, the next battle -- and the save
-- waits for one with nothing lost but a few minutes of nothing happening.
-- So the grace window is the route path's, and the route path is what SAVE
-- ON LOADS switches OFF. With the row off it is still there, because then
-- the route is the only place a save can ever go and a settled frame is the
-- best of them.
--
-- BOTH OF THEM BELONG TO THE BUILD WITH NOWHERE ELSE TO WRITE.
--
-- That is what the paragraph above was reaching for and did not finish.
-- `quietFrame` finished it -- it takes the grace window only when SAVE ON
-- LOADS is off -- and this function did not, so on the DEFAULT build, where
-- a covered screen is always coming, a save still landed the moment a menu
-- closed. Reported as exactly that: "closing menus / standing still
-- shouldn't trigger an auto save".
--
-- They are right, and the reason is already written above: with SAVE ON
-- LOADS on there is nothing to buy. The next door, the next warp, the ride
-- back out of the next battle -- `writeUnderCover` takes one of those and
-- the player sees none of it. A route window only costs a hitch somewhere
-- they are looking.
--
-- So on the default build the route has NO window: not a settled frame, not
-- a real stop. With SAVE ON LOADS off the route is the only place a save
-- can ever go, and there both come back -- a real stop first, because
-- standing still with no direction held is the player not doing anything.
--
-- Nothing is written less often than it was, only somewhere else:
-- `loadScreenWrite` asks for the same three things this path does -- due,
-- dirty, and MIN_GAP since the last one -- so a save that was going to
-- happen still happens, at the next covered screen instead of on the route.
-- The honest cost is that a player who walks a long way without a door, a
-- warp or a battle waits longer for one; the events that mark the file
-- dirty are mostly the ones that lead to a fade soon after, so in practice
-- that wait is short.
local function writeWindow(game)
local ow = worldOf(game)
if not (ow and ow.player) then return false end
if state.inBattle or state.outcomePending then return false end
if scriptRunning(ow) then return false end
if worldHolding(ow) then return false end
-- Ahead of screenOver, for the reason quietFrame gives above: a
-- transition IS a screen over the overworld, so the line below was
-- answering yes for the one frame this mod most needs to answer no for.
-- The ordinary due-save path could therefore write in the middle of a
-- warp's fade, which is the animation being cut rather than covered. A
-- write that WANTS to be under the black screen does not come through
-- here; it comes through loadScreenWrite, which decides for itself.
if worldFading(ow) then return false end
-- A screen over the overworld is a REFUSAL here, not a window. A write
-- that wants to go under one the player cannot press through -- a warp's
-- black screen, a battle's return hold -- comes through loadScreenWrite,
-- which names its own moments rather than taking every screen there is.
if screenOver(game) then return false end
if ow.player.moving then return false end
if walking(game, ow) then return false end
-- the same line quietFrame draws, in the same place, for the same reason
if mod.options:get("on_load") ~= false then return false end
if state.stillFor >= STILL_FOR then return true end
return state.settledAt ~= nil
and state.clock - state.settledAt <= SETTLE_GRACE
end
-- Kept per frame by the update pump. `stillFor` is unbroken seconds of
-- standing on the route doing nothing; `settledAt` is when the game last
-- handed control back -- a menu closed, a conversation ended, a battle
-- finished -- which is a window in its own right, because the player is
-- standing exactly where the game left them and has not moved yet.
-- Declared here, assigned far below where the battle wiring lives: the
-- pump calls it and a local declared after its use is a GLOBAL read, which
-- is nil. That exact mistake took the battle UI out in 0.32.3.
local releaseOutcome
local function trackStillness(game, dt)
local ow = worldOf(game)
local held = state.inBattle or screenOver(game) or scriptRunning(ow)
or (ow and ow.player and (worldHolding(ow) or worldFading(ow))) or false
if state.wasHeld and not held then state.settledAt = state.clock end
state.wasHeld = held and true or false
if releaseOutcome then releaseOutcome(game, dt, held) end
if ow and ow.player and not held and not ow.player.moving
and not walking(game, ow) then
state.stillFor = state.stillFor + dt
else
state.stillFor = 0
end
end
-- Everything sync is asked goes through pcall: a host with sync compiled
-- out, or a Gen 2 one, answers none of these questions and must not take
-- the autosave down with it.
local function syncEngineOf(game)
local ok, engine = pcall(function() return game:syncEngine() end)
if not ok or type(engine) ~= "table" then return nil end
return engine
end
-- WHOSE conflict is it? engine.phase is one word for the whole engine, but
-- the disagreement it stands for is always about one save: the planner walks
-- every local file it can see and raises a row per key that changed on both
-- ends (SyncEngine:_planFrom -> _addConflict), so an old playthrough this
-- account also has on another device turns the phase to "conflict" while the
-- file we are playing is not in dispute at all. Holding this game's writes
-- for that buys nothing -- our save is not the one with two sides.
--
-- engine.protectedKey is the key of the save being played. It is not stale:
-- Game:syncEngine() re-stamps it from the live save on every call, and
-- syncEngineOf just made that call.
local function conflictIsOurs(engine)
local key, rows
pcall(function() key = engine.protectedKey end)
pcall(function() rows = engine.conflicts end)
-- An engine that cannot say which save it means, or which are in dispute,
-- gets the careful answer: assume the hold is ours and leave the file be.
if type(key) ~= "string" or type(rows) ~= "table" then return true end
for _, row in ipairs(rows) do
if type(row) == "table" and row.key == key then return true end
end
return false
end
-- Which conflict, in the terms the launcher's own screen uses for it: two
-- savedAt stamps and the key they disagree about. "A conflict is standing"
-- is not something a player can go and check; "this device 21:42, other
-- device 21:37" is the same line the SAVE SYNC screen will show them.
local function stampText(meta)
local at = type(meta) == "table" and tonumber(meta.savedAt)
local ok, text = pcall(os.date, "%Y-%m-%d %H:%M", at or 0)
return (at and ok) and text or "?"
end
local function conflictDetail(engine)
local key, rows
pcall(function() key = engine.protectedKey end)
pcall(function() rows = engine.conflicts end)
if type(rows) ~= "table" then return nil end
for _, row in ipairs(rows) do
-- no key to match on means we already gave the hold the careful reading,
-- so describe the row that reading was about: the first one
if type(row) == "table" and (type(key) ~= "string" or row.key == key) then
return string.format("%s (this device %s, other device %s)",
tostring(row.key), stampText(row.localMeta),
stampText(row.remoteMeta))
end
end
return nil
end
-- ---------- the conflict that is not one
--
-- "These saves were played at the same time" does not mean two people.
-- SyncEngine.overlaps compares [sessionStart, savedAt] on the two sides, and
-- ONE sessionStart covers a whole play session: Game.sessionStartedAt is
-- stamped at init and at load, and SaveData.buildMeta copies it into every
-- save meta written until the game is closed. So two revisions of one
-- session always overlap, and the wording is about the intervals rather than
-- about devices.
--
-- When both sides carry the SAME sessionStart they are not two sessions at
-- all. A second device would have called os.time() for its own, on its own
-- load of this playthrough; matching to the second is not something two
-- machines do. It is one device's file, twice.
--
-- Which happens with nobody doing anything wrong. An upload the server
-- commits but whose reply never lands -- a dropped connection, a phone
-- putting the app to sleep -- leaves state.revs behind the rev the server
-- now has. The next plan then reads a save that moved on both ends:
-- localChanged because we kept playing, remoteChanged because our own upload
-- did arrive after all. Nothing on this side can prevent that; by the time
-- we could look, the reply is already lost. It can only be recognised
-- afterwards, and it has exactly one honest answer -- keep this device,
-- which is the far copy's own successor rather than a rival to it.
local function selfConflict(engine)
local key, rows
pcall(function() key = engine.protectedKey end)
pcall(function() rows = engine.conflicts end)
if type(key) ~= "string" or type(rows) ~= "table" then return nil end
for _, row in ipairs(rows) do
if type(row) == "table" and row.key == key then
local mine = type(row.localMeta) == "table" and row.localMeta
local theirs = type(row.remoteMeta) == "table" and row.remoteMeta
if not (mine and theirs) then return nil end
local ourStart, theirStart =
tonumber(mine.sessionStart), tonumber(theirs.sessionStart)
local ourSaved, theirSaved =
tonumber(mine.savedAt), tonumber(theirs.savedAt)
-- All of it, or it is a real disagreement and none of our business:
-- one session, said by both sides, and the far side is the revision
-- ours came after rather than one that went somewhere else.
if ourStart and theirStart and ourStart > 0 and ourStart == theirStart
and ourSaved and theirSaved and theirSaved <= ourSaved then
return row
end
return nil -- one row per key; this was ours and it did not fit
end
end
return nil
end
-- Answer it once, the way the player would have had to. resolveConflict is
-- the launcher's own "Keep this device" button: it points state.revs at the
-- rev we never heard about and force-uploads the file we are actually
-- playing, which is what the lost reply was trying to say to begin with.
--
-- Capped, because a heal that keeps coming back is a heal that is wrong
-- about something. After HEAL_TRIES on one key the mod stops and lets the
-- badge and the launcher have it, which is where this used to start.
local function healSelfConflict(engine)
if not mod.options:get("heal") then return false end
if type(engine.resolveConflict) ~= "function" then return false end
local row = selfConflict(engine)
if not row then return false end
if state.healKey ~= row.key then
state.healKey, state.healTries = row.key, 0
end
if state.healTries >= HEAL_TRIES then return false end
state.healTries = state.healTries + 1
local ok, done = pcall(engine.resolveConflict, engine, row.key, "local")
if not (ok and done ~= false) then return false end
mod.log:info("autosave healed a self-conflict on %s: our own upload from "
.. "%s, against the file we are still playing (%s)",
tostring(row.key), stampText(row.remoteMeta), stampText(row.localMeta))
return true
end
local function syncConflicted(engine)
local conflict = false
pcall(function() conflict = engine.phase == "conflict" end)
return conflict and conflictIsOurs(engine)
end
local function syncTransferring(engine)
local busy = false
pcall(function() busy = engine:busy() == true end)
return busy
end
-- A transfer in flight or a conflict waiting on the player are both reasons
-- to hold the file still: writing now either races the upload or adds a
-- third revision to a disagreement the player has not answered yet.
local function syncSettled(game)
if state.clock < state.syncWaitUntil then return false end
local engine = syncEngineOf(game)
if not engine then return true end
local conflict = syncConflicted(engine)
if conflict or syncTransferring(engine) then
state.syncWaitUntil = state.clock + SYNC_RETRY
return false, conflict and "conflict" or "transfer"
end
return true
end
-- Does the write we just made still owe sync something? uploadAt is the
-- engine's own debounce field -- set five seconds out by the
-- noteSaveWritten inside writeSave, cleared when the upload starts -- and
-- busy() covers the request in flight plus the queue behind it. Both have
-- to be clear before the save is anywhere but this device.
--
-- It is read and written directly because there is no accessor for it. A
-- conflict answers neither question: nothing is going up until the player
-- resolves it, so it reads as settled and the quit stops waiting.
local function syncOwesUpload(engine)
local owed = false
pcall(function()
owed = engine.uploadAt ~= nil or engine:busy() == true
end)
return owed
end
-- ---------- what a write costs after the write
--
-- The lag around an autosave is mostly not the write. Two things land a
-- beat later, which is why they read as the game stuttering rather than as
-- the game saving.
-- The first is the collector catching up on what the write threw away.
--
-- A save is not steady state. SaveData.save copies the progress table,
-- serializes the copy into one large string, reads the previous file back
-- to make the .bak, then writes that string twice more -- and rewrites
-- options.lua alongside it. With backups on, the checkpoint deep-copies
-- the save twice on top of that and mod storage serializes the result
-- again. Megabytes of short-lived strings and tables, all in one frame:
-- measured, one encode of a 182 KB save allocates about 2.8 MB.
--
-- SO NUDGE THE COLLECTOR, DO NOT RUN IT. This was 12 steps of 4096 KB --
-- up to 48 MB of allocation credit, which on any heap smaller than that is
-- a COMPLETE collection cycle, fired after every single write. It was
-- written to keep the cycle's tail off the route afterwards, and it did
-- that; what it also did was buy the tail with a spike four times bigger
-- than the save it was attached to.
--
-- Measured on a 45 MB heap, LuaJIT, 30 save cycles with 20 s of ordinary
-- frames between them -- median frame the save lands in:
--
-- 12 x step 4096 (what this was) 53.0 ms
-- no nudge at all 14.3 ms
-- 2 x step 512 (what this is) 10.5 ms
--
-- and the frames afterwards are the same either way: the worst frame in the
-- twenty seconds following a save is 4.1 ms with the old burst, 5.9 ms with
-- this, 10.2 ms with no nudge at all. The burst was paying 40 ms a save to
-- move at most 6 ms off a later frame. On a phone every one of those
-- numbers is three to five times larger, which is what "the game stutters
-- every time it autosaves" was.
--
-- The reason the small nudge is enough is that the engine is ALREADY doing
-- this: Game:update ends on collectgarbage("step", 1) every rendered frame,
-- and its own comment says why -- to spread collection out "so the default
-- lazy schedule never batches it into a visible pause". The collector was
-- never being left behind. This adds a little credit for the one frame
-- that allocated far more than a frame usually does, and nothing more.
--
-- Stop early the moment a step reports the cycle finished. The step
-- argument is kilobytes of allocation for the collector to account for,
-- which is how LuaJIT (the host) and 5.4 (the test harness) both read it.
local function settleGarbage()
if type(collectgarbage) ~= "function" then return end
for _ = 1, GC_STEPS do
local ok, finished = pcall(collectgarbage, "step", GC_STEP)
if not ok or finished then return end
end
end
-- ---------- and the frame after the hitch
--
-- A save is a hitch inside one logic step, and a hitch inside a logic step
-- does not just cost that frame -- it costs the animation running over it.
--
-- The engine advances logic in whole 1/60 steps out of an accumulator
-- (src/core/FixedStep.lua). A frame that took 60ms hands the NEXT update a
-- dt of 60ms, and the accumulator pays that back as four steps in a row
-- before anything is drawn again. Four steps of a fade in one frame is not
-- a fade, it is a cut: the black screen a warp puts up gets skipped and the
-- player pops into the new map.
--
-- The engine knows this and has a remedy for its own hitches:
-- FixedStep:discardCatchup drops the pending catch-up and absorbs the
-- oversized frame as a single step, so the animation plays out step by step
-- and simply takes a little longer in wall-clock. OverworldState:
-- crossConnection calls it after a map seam for exactly this reason.
--
-- It is not called for a warp. FixedStep's own comment says so in as many
-- words -- "crossConnection ... is the only caller of discardCatchup, and
-- warps go through Transition instead" -- so a hitch under a warp's fade has
-- nothing arming the clamp, and ours was the hitch.
--
-- So arm it ourselves, after anything of ours that costs a frame. This is
-- the engine's own call for the engine's own problem; the mod is only
-- admitting that it made one of the hitches.
--
-- Resolved lazily and once: a host without the module (a test harness, a
-- future engine that renamed it) simply gets a no-op, and the save is
-- unaffected either way.
local fixedStep, fixedStepTried = nil, false
-- Half a frame at 60Hz. os.clock is one of the four os functions the mod
-- sandbox keeps; a host without it simply never measures a hitch, and the
-- write path's own absorb is unconditional either way.
local HITCH_FLOOR = 0.008
local function clockNow()
if type(os) ~= "table" or type(os.clock) ~= "function" then return nil end
local ok, now = pcall(os.clock)
return ok and now or nil
end
local function absorbHitch()
if not fixedStepTried then
fixedStepTried = true
local ok, module = pcall(require, "src.core.FixedStep")
fixedStep = (ok and type(module) == "table"
and type(module.discardCatchup) == "function") and module or false
end
if not fixedStep then return end
pcall(function() fixedStep:discardCatchup() end)
end
-- The second is the upload the write woke.
--
-- writeSave tells the sync engine it happened, which arms a five second
-- debounce. When that fires the engine asks the server for its state and
-- plans against the reply on the main thread -- planning being
-- SyncEngine.defaultSaves's list(), which reads and DECODES every save slot
-- of every game version before any of it reaches a worker. A quarter-
-- megabyte slot costs tens of milliseconds, per slot, across six versions,
-- and a phone is worse.
--
-- THAT COST IS PLACED, NOT AVOIDED. This used to avoid it: at most one
-- autosave-woken upload every five minutes, every other write's debounce
-- disarmed, the file left for the engine's own sweep to carry up whenever it
-- next came round. It was cheaper and it was wrong. A save is not finished
-- when it reaches the disk; it is finished when it reaches the account, and
-- that policy left the newest save on one device for up to five minutes
-- while a second device could still be handed the old one. Nothing was
-- lost, but the save and the server were out of step for minutes at a time,
-- which is the whole thing sync is for.
--
-- So the upload goes with the save now, every time. What makes that
-- affordable is where the frames land, and both of those are already
-- decided: a write only happens in a window the player cannot move in, and
-- the plan is held out of any frame the screen is moving in (see
-- holdSyncWhileWalking). A sync cycle costs what it costs; it just does not
-- cost it anywhere anybody is looking.
--
-- And it goes IMMEDIATELY. The five second debounce exists to coalesce a
-- burst of writes, and this mod does not make bursts -- MIN_GAP already puts
-- twenty seconds between any two. Five seconds of waiting is therefore dead
-- time in the worst possible place: a door's black screen is up NOW, and
-- five seconds later the player is halfway down the next corridor. Pulled
-- forward, the request leaves while that screen is still black and the reply
-- comes back to it, or to the frames just after it, instead of to a walk.
-- The loading screen is a little longer for it, which is the trade.
--
-- Only ever an upload writeSave itself armed: a nil uploadAt means sync is
-- off, unlinked, or has nothing to send, and none of those is ours to start.
local function pushUpload(game)
local engine = syncEngineOf(game)
if not engine then return end
pcall(function()
if engine.uploadAt then engine.uploadAt = engine.clock or 0 end
end)
end
-- And a sync cycle leaves the same debt behind it, more of it than the
-- write that caused it: planning decoded every slot into a full table
-- again, a character at a time. Left alone that drag lands after the
-- stall rather than with it, which is the same complaint one step further
-- along. Watch for a cycle finishing -- busy, then not busy -- and pay for
-- it in the frame it landed on.
--
-- Every cycle, not only the ones an autosave woke: the engine's own sweep,
-- a download, a conflict being resolved all decode the same slots, and
-- after pacing most cycles are the engine's rather than ours.
--
-- And it waits for a frame worth spending, the same as everything else here.
-- A cycle can finish on a frame the player is walking through -- the plan is
-- held out of those, but the transfer that follows it completes on network
-- time and answers to nothing -- and twelve collector steps landing there is
-- the same stutter by another route. So the debt is remembered and paid on
-- the first frame that is not a moving screen, which is at worst a fraction
-- of a second later.
local function settleSyncGarbage(game)
local engine = syncEngineOf(game)
if not engine then
state.syncWasBusy = false
return
end
if syncTransferring(engine) then
state.syncWasBusy = true
return
end
if state.syncWasBusy then
state.syncWasBusy = false
state.gcOwed = true
end
if not state.gcOwed then return end
if not quietFrame(game) then return end
state.gcOwed = false
settleGarbage()
end
-- And where the last of it lands.
--
-- Pacing chose how OFTEN a cycle runs. It could not choose WHEN the
-- expensive part of one happens, and that is the half the player actually
-- feels. The plan is built against the server's REPLY, not against the
-- request: SyncEngine:update polls the handle, and the frame the answer
-- lands on runs _planFrom, which is the decode of every slot. So the stall
-- arrives on network time -- a moment set by latency and the server, with no
-- relation to anything the player did. That is exactly why it reads as the
-- game hiccupping rather than as the game syncing: nothing on screen caused