-
Notifications
You must be signed in to change notification settings - Fork 22
Expand file tree
/
Copy pathmain.lua
More file actions
1300 lines (1217 loc) · 54.6 KB
/
Copy pathmain.lua
File metadata and controls
1300 lines (1217 loc) · 54.6 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
-- Copyright (c) 2026 Cedric. All rights reserved.
-- Source-available under the Gen2Recomped License (see LICENSE.md): you may
-- read, build and privately modify this file; you may not redistribute it or
-- use it commercially. Cartridge-derived data is excluded and is not the
-- copyright holder's to license.
-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private
-- game-data cache from a user-provided ROM on first boot.
--
-- The save editor (tools/save-editor/) ships inside every build and is
-- reachable two ways:
-- * standalone: POKEPORT_EDITOR=1 or `love . --editor`, its own window
-- * from the launcher: Edit on a save row, which suspends the launcher,
-- opens the editor on that slot's file, and restores the launcher when
-- the editor's Close button is pressed (openEditor / closeEditor below)
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
local Game, EditorApp, Importer, TouchEditor
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
-- coroutine that receives `Game` and yields once per
-- frame; used headless (xvfb) for scripted screenshots
-- --speed N / POKEPORT_SPEED=N: run the logic clock N times faster without
-- touching audio (src/core/GameSpeed.lua). Overrides the saved option so a
-- bot or screenshot run is not at the mercy of the player's last choice.
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
-- POKEPORT_TOUCH=1 forces the mobile on-screen controls on and lets the
-- mouse stand in for a finger, so the overlay can be exercised on desktop
-- (see src/core/TouchControls.lua).
local mouseTouch = os.getenv("POKEPORT_TOUCH") == "1"
-- How many times to run a scripted act+step loop per rendered frame. Only
-- scripted runs use this; interactive play fast-forwards through
-- Game.speedOverride / the GAME SPEED option instead.
local function scriptedIterations()
if not (autopilot or driverCo) then return 1 end
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
end
-- ------------------------------------------------------------ save editor
-- The launcher instance parked while the editor is up, plus the version whose
-- cache the editor mounted (so closing can put the read path back).
local editorHost, editorVersion, editorWindow
local closeEditor -- forward declaration: openEditor hands it to the editor
-- The editor's modules use flat names (require("Kit"), require("Party")), so
-- their directories have to be on the require path. It must be
-- love.filesystem's path, not package.path: in a packaged build these files
-- live inside the .love archive, which the stock Lua searcher cannot open.
local function addEditorRequirePath()
local fs = love.filesystem
if not (fs.setRequirePath and fs.getRequirePath) then
-- very old LOVE: a source checkout still resolves through package.path
package.path = fs.getSource() .. "/tools/save-editor/?.lua;"
.. fs.getSource() .. "/tools/save-editor/panels/?.lua;"
.. package.path
return
end
local current = fs.getRequirePath()
if current:find("tools/save%-editor") then return end
fs.setRequirePath("tools/save-editor/?.lua;tools/save-editor/panels/?.lua;"
.. current)
end
-- Desktop only: the launcher window (1024x768) is tighter than the editor's
-- design size, so grow it while editing and put it back on Close. Never
-- shrinks, never touches a fullscreen or mobile window.
local function resizeForEditor()
if not (love.window and love.window.getMode and love.window.setMode) then return end
local osName = love.system.getOS()
if osName ~= "OS X" and osName ~= "Windows" and osName ~= "Linux" then return end
local w, h, flags = love.window.getMode()
if flags.fullscreen then return end
local dw, dh = love.window.getDesktopDimensions()
local wantW = math.max(w, math.min(1360, math.floor((dw or w) * 0.92)))
local wantH = math.max(h, math.min(860, math.floor((dh or h) * 0.88)))
if wantW <= w and wantH <= h then return end
editorWindow = { w = w, h = h }
love.window.setMode(wantW, wantH, flags)
end
local function restoreWindow()
if not editorWindow then return end
local _, _, flags = love.window.getMode()
love.window.setMode(editorWindow.w, editorWindow.h, flags)
editorWindow = nil
end
-- Open the editor on a launcher save row. The version's cache has to be
-- mounted before the editor's Data:load runs, or a Blue save would be edited
-- against Red's species/item tables.
local function openEditor(version, slotId)
local SaveData = require("src.core.SaveData")
local path = SaveData.slotDiskPath(version, slotId)
if not path then
if Importer then
Importer.saveNotice = Importer.saveNotice or {}
Importer.saveNotice[version] =
{ ok = false, text = "Could not resolve that save slot on disk." }
end
return
end
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version)
-- Not optional, and the return value is not decoration. A failed overlay
-- surfaces several frames later inside the editor's Data:load as "missing
-- generated data module 'data/generated/constants.lua'" -- an uncaught error
-- raised out of a mouse handler, which on the Switch and on Android means
-- the app is simply gone. bootGame already checks this; the editor path was
-- the one that did not, so pressing Edit was a crash rather than a message.
local mounted, mountErr =
require("src.import.CacheFs").mountVersion(version)
if not mounted then
if Importer then
Importer.saveNotice = Importer.saveNotice or {}
Importer.saveNotice[version] = { ok = false, text =
("Cannot edit %s saves: its imported data is not loadable (%s). " ..
"Import the ROM again."):format(
GameVersion.info(version).displayName,
tostring(mountErr or "unknown reason")) }
end
return
end
editorVersion = version
editorHost = Importer
Importer = nil
editorMode = true
resizeForEditor()
addEditorRequirePath()
EditorApp = require("App")
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
onClose = function() closeEditor() end })
end
-- Open the MAP EDITOR on a game. Same shell, same mount discipline, same way
-- back -- the only differences are that there is no save slot to resolve (the
-- map editor edits the game's maps, not a playthrough) and that App.load is
-- told which mode to open in.
--
-- The mount is not optional here either, and for a sharper reason than in the
-- save path: without the version's cache the editor's Data:load finds no
-- generated map data at all, so the map list would come up EMPTY rather than
-- failing -- an editor that looks like it works and has nothing in it.
local function openMapEditor(version)
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version)
local mounted, mountErr =
require("src.import.CacheFs").mountVersion(version)
if not mounted then
if Importer then
Importer.saveNotice = Importer.saveNotice or {}
Importer.saveNotice[version] = { ok = false, text =
("Cannot edit %s maps: its imported data is not loadable (%s). " ..
"Import the ROM again."):format(
GameVersion.info(version).displayName,
tostring(mountErr or "unknown reason")) }
end
return
end
editorVersion = version
editorHost = Importer
Importer = nil
editorMode = true
resizeForEditor()
addEditorRequirePath()
EditorApp = require("App")
EditorApp.load(nil, { version = version, mode = "map", embedded = true,
onClose = function() closeEditor() end })
end
-- Back to the launcher. Everything the editor mounted or cached has to come
-- back out: the version overlay (CacheFs) and the generated modules require
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
-- with this one's data.
function closeEditor()
local version = editorVersion
editorMode = false
if EditorApp and EditorApp.unload then EditorApp.unload() end
EditorApp = nil
if version then
require("src.import.CacheFs").unmountVersion(version)
require("src.core.Data"):unloadGenerated()
end
editorVersion = nil
restoreWindow()
Importer = editorHost
editorHost = nil
if Importer and version and Importer.savesChanged then
Importer:savesChanged(version)
end
end
-- ------------------------------------------------------------ touch controls editor
-- Suspends the launcher while the player drags on-screen buttons / toggles
-- the overlay off (#327). No ROM cache needed -- options.lua only.
local touchEditorHost
local closeTouchControlsEditor -- forward declaration
local function openTouchControlsEditor()
touchEditorHost = Importer
Importer = nil
TouchEditor = require("src.ui.TouchControlsEditor")
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
end
function closeTouchControlsEditor()
if TouchEditor and TouchEditor.unload then TouchEditor.unload() end
TouchEditor = nil
Importer = touchEditorHost
touchEditorHost = nil
end
-- Boot breadcrumbs (src/core/BootTrace.lua). On Android and the Switch a
-- failed launch is a window that exists for a second and then does not, with
-- nothing to read afterwards: crash.txt below only ever catches a LUA error,
-- and a native abort -- an OOM kill, a JNI check, a driver fault -- reaches no
-- Lua handler and leaves it empty, which is indistinguishable from a clean
-- run. So record progress instead of failure: the last line in boot_trace.txt
-- is the stage the process did not survive.
local BootTrace = require("src.core.BootTrace")
local FrameProfile = require("src.core.FrameProfile")
local Frames = require("src.core.Frames")
-- wall clock of the last log flush (see love.run below)
local lastLogFlush = 0
-- Counts love.graphics.push/pop for the life of the process and unwinds
-- anything a frame forgot, at the top of the next update and draw. Installed
-- here, before anything can draw, because the failure it guards against is not
-- local: ONE unmatched push anywhere -- engine, mod, a menu preview that bailed
-- early -- leaves the shared stack a notch deeper every frame until some
-- unrelated push overflows it and ends the process with a traceback pointing at
-- innocent code. See src/render/GraphicsStack.lua.
local GraphicsStack = require("src.render.GraphicsStack")
-- Declared HERE rather than further down beside its first mark: bootGame below
-- calls BootTrace.booted(), and a `local` declared after a function's body is
-- not in scope inside it -- the name resolved to the nil GLOBAL instead and
-- killed the boot the moment an import finished (onComplete -> bootGame).
-- The version this process last booted, so the next boot can take its cache
-- overlay back off the read path and evict what was required through it.
local bootedVersion = nil
local function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
-- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
-- version and overlay its extracted cache BEFORE anything requires generated
-- data, so data/generated + assets/generated resolve to that version's files.
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
-- ONE PROCESS CAN BOOT TWO GAMES, AND `require` REMEMBERS THE FIRST.
--
-- Every version loads its data under the SAME module names -- Data:load
-- requires "data.generated.maps" whatever you are playing -- and which
-- files those names resolve to is decided by the cache overlay mountVersion
-- puts on the read path. So the second boot in a process has two ways to
-- get the first game's world, and it was taking both:
--
-- * the previous version's overlay was never unmounted, and Red's own
-- mountVersion returns immediately (`cachePrefix == ""`, Red owns the
-- root) without displacing it; and
-- * even with the overlay gone, package.loaded still holds the tables the
-- first game required under those names, so Data:load never reaches the
-- disk at all.
--
-- The result is a game running on another game's data: Red booted after
-- Crystal drew Crystal's maps with Crystal's tilesets, and Oak's Lab had
-- Crystal's four objects instead of the three starter POKe BALLs -- so the
-- room was wrong AND the starter could not be picked up, because the ball
-- the player was walking up to was not in the map they were standing in.
--
-- closeEditor has done exactly these two calls since the map editor could
-- open the other game's cache, and its comment says why: "pressing Play on
-- the OTHER game would boot it with this one's data". The Play path itself
-- was never given the same treatment.
--
-- ON DESKTOP THIS HID BEHIND THE PROCESS DYING. Quitting the app and
-- relaunching gets a clean Lua state, so "restart and it is fine" looked
-- like the whole story. Android does not oblige: backing out of the app
-- leaves the process resident, so reopening it resumes the same VM with the
-- same package.loaded -- which is why re-importing the ROM and starting a
-- new save changed nothing there. Both rewrite files that are never read
-- again.
--
-- Unconditional and idempotent: unmountVersion is a no-op for a version
-- that owns the root or was never mounted, and unloadGenerated on a first
-- boot evicts nothing because nothing has been required yet.
if bootedVersion and bootedVersion ~= GameVersion.get() then
pcall(function()
require("src.import.CacheFs").unmountVersion(bootedVersion)
end)
end
pcall(function() require("src.core.Data"):unloadGenerated() end)
bootedVersion = GameVersion.get()
-- A failed overlay is fatal a few lines later, inside Data:load, as
-- "missing generated data module 'data/generated/constants.lua'" -- an
-- error that names the symptom and hides the cause. Report the cause
-- here, where mountVersion can still say which mechanism failed and what
-- the player can do about it.
local mounted, mountErr =
require("src.import.CacheFs").mountVersion(GameVersion.get())
if not mounted then
error(("could not load %s's imported data (%s).\n\n" ..
"The import wrote it to:\n %s%s\n\n" ..
"If that folder is missing or empty, import the ROM again."):format(
GameVersion.info().displayName,
tostring(mountErr or "unknown reason"),
love.filesystem.getSaveDirectory and
(love.filesystem.getSaveDirectory() .. "/") or "",
GameVersion.cachePrefix(GameVersion.get())), 0)
end
-- ...AND THE PICTURES, WHICH THE OVERLAY DOES NOT MOVE.
--
-- Reported from play: "if i launch emerald, and then go to exit game and it
-- goes back to the launcher and then i load firered, its loading all the
-- data for emerald still ... it shows the emerald player, and when i talk to
-- people it shows code instead of their text", and then: "might occur for
-- other games too". It does -- every pair of them.
--
-- The two lines above are the DATA half of a version switch and they are
-- right: the overlay moves and the generated modules are evicted, so
-- `data.generated.maps` is re-read off the new cache. Art never goes
-- through `require`. It is loaded by PATH through Assets.image, and every
-- version spells those paths identically -- "assets/generated/sprites/..."
-- is the player on all of them -- so what the overlay redirects on disk the
-- cache in front of it hands straight back from the game before. The
-- player kept Emerald's body, and the dialogue kept Emerald's glyph sheet,
-- which is what "code instead of their text" is: FireRed's text codes
-- indexed into Hoenn's font pages.
--
-- Assets.invalidate is exactly this flush and it already fans out to every
-- downstream cache that was built from those images -- the map atlases and
-- the loaded Maps holding them, the sprite sheets, the font pages, the HUD
-- tiles, the battle art, the menu screens and the sound bank. It had one
-- caller, the mod loader, and a version switch is the same event: the files
-- under those paths have changed.
--
-- After the mount, never before: it drops the cache, and a lookup between
-- the two would refill it through the overlay that is on its way out.
-- Unconditional, because a first boot has nothing cached and every
-- invalidator is a table-drop.
pcall(function()
local dropped = require("src.render.Assets").invalidate()
require("src.core.Logger").info(
"version switch: dropped %s cached image(s) so the new cache's own art "
.. "is loaded rather than the last game's",
tostring(dropped))
end)
if love.window and love.window.setTitle then
local Version = require("src.core.Version")
love.window.setTitle(Version.title(
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
end
Game = require("src.core.Game")
Game:load()
BootTrace.booted()
if os.getenv("POKEPORT_AUTOPILOT") then
autopilot = require("tests.autopilot")
end
local driverPath = os.getenv("POKEPORT_DRIVER")
if driverPath then
local fn = assert(loadfile(driverPath))()
driverCo = coroutine.create(fn)
end
-- After the two above are known: a scripted run drives the multiplier
-- from love.update's loop, so the in-engine one must stay at 1 or the
-- two would compound (10x10 = 100 steps per observation).
Game.speedOverride = (autopilot or driverCo) and 1 or speedOverride
end
-- THE LAUNCHER, BUILT IN ONE PLACE.
--
-- love.load raises it at boot and the in-game menus raise it again on the way
-- out, and the two must agree about what a launcher is -- the Edit buttons on
-- a save row exist only because these opts are passed, and a second
-- construction that forgot them would quietly drop the editor.
local function openLauncher(forceImport)
local RomImporter = require("src.import.RomImporter")
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, {
launcher = true,
forceImport = forceImport,
onEditSave = openEditor,
onEditMaps = openMapEditor,
onEditTouchControls = openTouchControlsEditor,
})
end
-- ...AND THE WAY BACK TO IT FROM A RUNNING GAME.
--
-- Until now the only exit a game offered was love.event.quit: EXIT GAME on
-- the main menu and QUIT GAME in the Gen 3 start menu both killed the
-- process, which on a launcher build is the wrong door -- the player wanted
-- the game list, not the desktop.
--
-- Nothing needs tearing down here beyond letting go of `Game`: bootGame is
-- already written to run twice in one process and does the whole job on the
-- way back IN (unmount the old cache overlay, evict the generated modules,
-- mount and reload), which is the same work whether the next pick is this
-- game or another one. Doing it here as well would only mean doing it
-- twice, and would leave the launcher itself running with no data mounted.
--
-- The music is this side's business, though: it is playing right now, and
-- the launcher has its own.
local function returnToLauncher()
if Importer then return end
pcall(function() require("src.core.Music").stop() end)
-- The launcher's bottom-screen shelf starts fresh, and its canvas is not
-- held across a whole play session.
pcall(function() require("src.ui.LauncherSecondScreen").forget() end)
autopilot, driverCo = nil, nil
Game = nil
openLauncher(false)
end
-- The game reaches this through HostShell, the way it already reaches the
-- process restart mods need (Game:restartWithMods). Registered here because
-- main.lua owns `Importer` and nothing below it can.
require("src.core.HostShell").setLauncherHook(returnToLauncher)
-- Chunk level, not inside love.load: this is the earliest point Lua code of
-- ours runs with a writable save directory (conf.lua has already applied
-- t.identity by now). So NO boot_trace.txt at all is itself a result -- it
-- means the failure was in conf.lua or below it in the engine, before any of
-- this file ran.
BootTrace.mark("main.lua chunk")
-- ------------------------------------------------------- boot failure report
-- What the player sees after a launch that died, INSTEAD of booting.
--
-- The trace and crash.txt only help if someone can read them, and on Android
-- nobody can: t.externalstorage puts the save directory under
-- Android/data/<package>/files/, and Android 11 closed that path to every
-- third-party file manager. The app writes there fine and the player gets
-- "permission denied". adb is the only other way out, and asking a player for
-- adb is asking them to give up.
--
-- So the report comes back through the screen. Launch N dies; launch N+1
-- notices the trace has no "clean exit", shows it, and -- critically -- does
-- NOT run the boot path that just failed, so the report cannot be taken down
-- by the same crash. A tap dismisses it and boots normally, so a one-off is
-- not a lock-out.
local bootReport = nil -- { text = string, args = table }
local realLoad -- forward declaration: the actual boot
local function drawBootReport()
local w, h = love.graphics.getDimensions()
love.graphics.clear(0.08, 0.09, 0.13)
love.graphics.setColor(1, 0.55, 0.35)
local pad = math.max(12, math.floor(w * 0.03))
if not bootReport.font then
-- Small, because a traceback on a phone is long and legibility here means
-- "fits", not "looks nice".
bootReport.font = love.graphics.newFont(math.max(9, math.floor(h / 62)))
end
love.graphics.setFont(bootReport.font)
love.graphics.printf(bootReport.text, pad, pad, w - pad * 2, "left")
love.graphics.setColor(0.6, 0.7, 0.9)
love.graphics.printf("tap / press any key to continue booting",
pad, h - pad - bootReport.font:getHeight() * 2, w - pad * 2, "left")
end
local function dismissBootReport()
local args = bootReport.args
bootReport = nil
BootTrace.mark("boot report dismissed")
realLoad(args)
end
function love.load(args)
BootTrace.mark("love.load enter")
GraphicsStack.install()
pcall(function() require('src.core.SwitchAudioCheck').run() end)
-- Teach SDL about the pads its built-in database misses (Switch Pro over
-- Bluetooth, Joy-Cons, GameSir) before any joystick is opened, so they
-- arrive as real gamepads with leftx/lefty instead of as raw numbered axes
-- nothing was listening to. See GamepadMap.loadMappings.
pcall(function() require("src.core.GamepadMap").loadMappings() end)
-- Before anything else, and before anything that could fail: did the last
-- launch finish? POKEPORT_NO_BOOT_REPORT=1 skips this for CI and scripted
-- runs, which have no one to show it to and must not stop on it.
--
-- POKEPORT_PAYLOAD_MOUNTED skips it too, and that one is not an opt-out: it
-- means we ARE the chainloaded payload and this love.load is the second one
-- of a single launch (src/update/Boot.lua's chainload runs the payload's
-- main.lua and then calls its love.load). The bundled half already asked
-- this question a few milliseconds ago, with the real previous trace in
-- front of it. Asking again diagnosed the launch in progress as a failed
-- one -- an updated Android build opened with "PREVIOUS LAUNCH DID NOT
-- FINISH" every single time, showing its own bundled prefix ending at
-- "save identity". BootTrace inherits the verdict across the handoff too,
-- so this is belt and braces rather than the fix.
if os.getenv("POKEPORT_NO_BOOT_REPORT") ~= "1"
and not _G.POKEPORT_PAYLOAD_MOUNTED then
local report = BootTrace.previousFailureReport()
if report then
BootTrace.mark("showing previous failure report")
bootReport = { text = report, args = args }
return
end
end
return realLoad(args)
end
function realLoad(args)
-- Before anything can shell out (update check, mod index, ROM picker),
-- claim one hidden console on Windows so those children inherit it instead
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
require("src.core.HostShell").hideHostConsole()
BootTrace.mark("host console")
-- Gen2Recomped used to share the LÖVE identity "pokemon-love2d" with the
-- Gen 1 port, so both games wrote one save folder. We own "Gen2Recomp"
-- now; copy an existing player's saves across the first time. Must run
-- before Boot.run or anything else reads the save directory.
pcall(function() require("src.core.SaveIdentity").migrateLegacy() end)
BootTrace.mark("save identity")
-- Self-updater boot shell: a fused build may mount and chainload a newer
-- downloaded payload here. True means it took over, so we must stop. A
-- dev / source checkout no-ops (see src/update/Boot.lua).
local Boot = require("src.update.Boot")
if Boot.run(args) then
BootTrace.mark("payload chainloaded")
return
end
BootTrace.mark("boot shell")
local savePath
for i, a in ipairs(args or {}) do
if a == "--editor" then
editorMode = true
elseif a == "--developer" then
_G.POKEPORT_DEV_MODE = true
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
savePath = args[i + 1]
elseif a == "--speed" and tonumber(args[i + 1]) then
speedOverride = tonumber(args[i + 1])
end
end
love.graphics.setDefaultFilter("nearest", "nearest")
-- Standalone editor. A bare `--editor` run has no launcher behind it, so
-- Close quits; --save points it at a specific file, otherwise it opens the
-- default save path for POKEPORT_VERSION (Red unless overridden), whose
-- cache has to be mounted before the editor's Data:load.
if editorMode then
local version = os.getenv("POKEPORT_VERSION") or "red"
require("src.core.GameVersion").set(version)
require("src.import.CacheFs").mountVersion(version)
addEditorRequirePath()
EditorApp = require("App")
EditorApp.load(savePath, { version = version })
return
end
local RomImporter = require("src.import.RomImporter")
BootTrace.mark("RomImporter required")
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
-- Scripted / headless runs pick their game from POKEPORT_VERSION (default
-- Red); the launcher's per-column choice does not apply to them.
local scriptedVersion = os.getenv("POKEPORT_VERSION") or "red"
local ready = RomImporter.isReady(scriptedVersion)
-- Scripted / headless runs have to reach the game with no human pressing
-- Play: an autopilot, a frame driver, an import-only build step, or an
-- explicit ROM path all bypass the interactive launcher and keep today's
-- import-then-boot (or boot-straight-in) behavior.
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or importPath ~= nil
if scripted then
if forceImport or not ready then
-- The importer detects the dropped/loaded ROM's version by SHA-1 and
-- passes it to onComplete; boot that version.
Importer = RomImporter.new(function(version)
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
love.event.quit()
return
end
Importer = nil
bootGame(version or scriptedVersion)
end)
if importPath then Importer:startPath(importPath) end
return
end
bootGame(scriptedVersion)
return
end
-- Interactive: the launcher always runs. Red, Blue, and Yellow are each
-- live: a column shows Play when that game's ROM is already imported, or
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
-- SHA-1 (GameVersion.forSha1); pressing Play boots that game. Edit on a
-- save row opens the bundled editor on that slot (openEditor).
BootTrace.mark("launcher: constructing")
openLauncher(forceImport)
BootTrace.mark("launcher: ready")
BootTrace.booted()
end
function love.update(dt)
-- Unwind anything the previous frame left on the graphics stack before this
-- one starts building on top of it.
do
local close = FrameProfile.section(" update: graphics stack drain")
GraphicsStack.drain()
close()
end
-- The report deliberately runs nothing else: the boot path it is reporting on
-- is the code that just died.
if bootReport then return end
if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end
if Importer then return Importer:update(dt) end
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
-- once per Game:update, so they must keep a 1:1 relationship with the
-- logic step. Fast-forwarding them by scaling the step inside
-- Game:update would run N steps per observation: a held direction walks
-- through all N, the player slides past the waypoint, and the script
-- re-plans from an overshot cell. So iterate the whole act+step loop
-- instead -- same script, just more of it per rendered frame.
local iterations = scriptedIterations()
if autopilot then
for _ = 1, iterations do
autopilot.update()
Game:update(1 / 60) -- deterministic stepping for the autopilot
end
return
end
if driverCo then
for _ = 1, iterations do
local ok, err = coroutine.resume(driverCo, Game)
if not ok then
print("driver error: " .. tostring(err))
love.event.quit(1)
return
end
if coroutine.status(driverCo) == "dead" then
love.event.quit()
return
end
Game:update(1 / 60)
end
return
end
-- Game is nil until a ROM has been imported and Play pressed, and it is nil
-- again for any window in which neither the launcher nor the game owns the
-- frame: while the boot report is up, after Boot.run chainloads a payload,
-- and between the launcher clearing Importer and bootGame assigning Game.
-- Android delivers focus/visible events during all of those, so an
-- unguarded dereference here is not a theoretical race -- it is
-- "attempt to index upvalue 'Game' (a nil value)" a second after launch.
if not Game then return end
Game:update(dt)
end
-- THE LAUNCHER'S OWN BOTTOM SCREEN.
--
-- Reported from play: "its still not detecting the second screen on the ayn
-- thor ... maybe the launcher itself has to intialize with it". It does, and
-- this is the line that was missing: `SecondScreen.flush` was called only from
-- `Game:draw`, and every branch of `love.draw` below returns before reaching
-- it while the launcher, an editor or the boot report owns the window. So no
-- frame was ever sent to the panel until a cartridge was running -- which
-- looks exactly like a second display that was never detected.
--
-- Inert unless a real second display is attached: see LauncherSecondScreen.
local function drawSecondPanel()
-- A running game draws its own bottom screen and flushes it at the end of
-- Game:draw, which is where the readback belongs. This is only for the
-- frames that never get there.
if Game then return end
local ok, LSS = pcall(require, "src.ui.LauncherSecondScreen")
if ok and LSS and LSS.tick then pcall(LSS.tick, Importer) end
end
function love.draw()
GraphicsStack.drain()
if bootReport then return drawBootReport() end
if editorMode then EditorApp.draw() return drawSecondPanel() end
if TouchEditor then TouchEditor.draw() return drawSecondPanel() end
if Importer then Importer:draw() return drawSecondPanel() end
if not Game then return drawSecondPanel() end
Game:draw()
-- frame capture requested by a driver
if Game.capturePath then
local path = Game.capturePath
Game.capturePath = nil
love.graphics.captureScreenshot(function(imagedata)
local fd = imagedata:encode("png")
local f = io.open(path, "wb")
if f then
f:write(fd:getString())
f:close()
end
end)
end
end
function love.keypressed(key, scancode, isrepeat)
if bootReport then return dismissBootReport() end
if editorMode then return EditorApp.keypressed(key) end
if TouchEditor then return TouchEditor.keypressed(key) end
if Importer then return Importer:keypressed(key) end
if not Game then return end
Game:keypressed(key)
end
function love.keyreleased(key)
if editorMode or TouchEditor then return end
if Importer then return end
if not Game then return end
Game:keyreleased(key)
end
-- The editor gets the pad events too.
--
-- These handlers all began with `if editorMode ... then return end`, which was
-- written when the editor was a desktop-only tool reached by `love . --editor`
-- and a mouse was a given. It is now reachable from the launcher on every
-- platform, including two with no pointer at all: on the Switch the editor
-- opened, drew, and then ignored every button, stick and D-pad input the
-- console can produce -- the only way out was to kill the app. The launcher
-- already solves this with a virtual cursor (RomImporter:_activatePadCursor);
-- the editor now has the same one, and these forwards are what feed it.
function love.gamepadpressed(joystick, button)
-- and the dismissal that matters on the Switch.
if bootReport then return dismissBootReport() end
if TouchEditor then return end
if editorMode then
if EditorApp.gamepadpressed then return EditorApp.gamepadpressed(button) end
return
end
if Importer then return Importer:gamepadpressed(joystick, button) end
if not Game then return end
Game:gamepadpressed(joystick, button)
end
function love.gamepadreleased(joystick, button)
if TouchEditor then return end
if editorMode then
if EditorApp.gamepadreleased then return EditorApp.gamepadreleased(button) end
return
end
if Importer then return Importer:gamepadreleased(joystick, button) end
if not Game then return end
Game:gamepadreleased(joystick, button)
end
function love.gamepadaxis(joystick, axis, value)
if TouchEditor then return end
if editorMode then
if EditorApp.gamepadaxis then return EditorApp.gamepadaxis(axis, value) end
return
end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
if not Game then return end
Game:gamepadaxis(joystick, axis, value)
end
function love.joystickpressed(joystick, button)
if bootReport then return dismissBootReport() end
if TouchEditor then return end
if editorMode then
if EditorApp.joystickpressed then
return EditorApp.joystickpressed(joystick, button)
end
return
end
if Importer then return Importer:joystickpressed(joystick, button) end
if not Game then return end
Game:joystickpressed(joystick, button)
end
function love.joystickreleased(joystick, button)
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickreleased(joystick, button) end
if not Game then return end
Game:joystickreleased(joystick, button)
end
function love.joystickaxis(joystick, axis, value)
if TouchEditor then return end
if editorMode then
if EditorApp.joystickaxis then
return EditorApp.joystickaxis(joystick, axis, value)
end
return
end
if Importer then return Importer:joystickaxis(joystick, axis, value) end
if not Game then return end
Game:joystickaxis(joystick, axis, value)
end
function love.joystickhat(joystick, hat, direction)
if TouchEditor then return end
if editorMode then
if EditorApp.joystickhat then
return EditorApp.joystickhat(joystick, hat, direction)
end
return
end
if Importer then return Importer:joystickhat(joystick, hat, direction) end
if not Game then return end
Game:joystickhat(joystick, hat, direction)
end
function love.joystickremoved(joystick)
if editorMode or TouchEditor then return end
if Importer then return end
if not Game then return end
Game:joystickremoved(joystick)
end
-- f is true on focus gained, false on focus lost (e.g. alt-tab). A held
-- direction's key-up can be delivered to the OS instead of the game while
-- unfocused, so reset input on either transition rather than trust it.
function love.focus(f)
if bootReport then return end
if editorMode or TouchEditor then return end
if Importer then
if Importer.focus then Importer:focus(f) end
return
end
if not Game then return end
Game:focus(f)
end
-- v is true when the window becomes visible again, false on minimize.
function love.visible(v)
if bootReport then return end
if editorMode or TouchEditor then return end
if Importer then return end
if not Game then return end
Game:visible(v)
end
function love.touchpressed(id, x, y, dx, dy, pressure)
-- The dismissal that matters on a phone: there is no keyboard behind this.
if bootReport then return dismissBootReport() end
-- THE EDITOR SEES TOUCHES NOW, and only for the gestures a mouse cannot
-- make. It is driven by SDL's synthesized mouse events -- one finger is a
-- pointer and always has been -- so these handlers track contact points and
-- act on TWO of them (pinch to zoom) and nothing else. Returning without
-- consuming keeps every existing single-finger path exactly as it was.
if editorMode then
if EditorApp.touchpressed then EditorApp.touchpressed(id, x, y) end
return
end
if TouchEditor then
-- iOS synthesizes mousepressed for the primary touch (same as the
-- launcher); Android drives the editor through love.touch directly.
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchpressed(id, x, y)
end
if Importer then
-- iOS: LÖVE already synthesizes a mousepressed for the primary touch,
-- and love.mousepressed below forwards that to the Importer, so
-- forwarding here too fires every launcher button twice per tap. The
-- resulting double-present was fatal for the document picker: the
-- second sheet stole the first one's weakly-held delegate, so picking
-- a file silently did nothing. Android keeps the forward for upstream
-- parity (its SAF picker is a separate activity and tolerates the
-- re-launch).
if love.system.getOS() == "iOS" then return end
return Importer:mousepressed(x, y, 1)
end
if not Game then return end
Game:touchpressed(id, x, y)
end
function love.touchmoved(id, x, y, dx, dy, pressure)
if editorMode then
if EditorApp.touchmoved then EditorApp.touchmoved(id, x, y) end
return
end
if TouchEditor then
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchmoved(id, x, y)
end
if Importer then return end
if not Game then return end
Game:touchmoved(id, x, y)
end
function love.touchreleased(id, x, y, dx, dy, pressure)
if editorMode then
if EditorApp.touchreleased then EditorApp.touchreleased(id, x, y) end
return
end
if TouchEditor then
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchreleased(id, x, y)
end
if Importer then return end
if not Game then return end
Game:touchreleased(id, x, y)
end
function love.wheelmoved(x, y)
if editorMode then
if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end
return
end
if TouchEditor then return end
if Importer then return end
if not Game then return end
Game:wheelmoved(x, y)
end
function love.mousepressed(x, y, button, istouch)
if bootReport then return dismissBootReport() end
if TouchEditor then
-- Android primary touch already arrived via love.touchpressed; a second
-- mouse path would double-fire Done / begin a second drag.
if love.system.getOS() == "Android" then return end
return TouchEditor.mousepressed(x, y, button)
end
if Importer then
-- The same double-fire TouchEditor guards against, which the launcher was
-- missing: love.touchpressed above forwards the primary touch to the
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
-- same touch, so one tap ran every launcher button twice. On Import that
-- meant two choose() calls and two stacked SAF picker activities: the
-- player picked their ROM, the top picker closed, and the second was still
-- underneath asking for it again, which is the "import the file twice"
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
-- USB mouse) working, which an Android-wide return would have broken.
--
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
-- returns early on iOS and never forwards, so there the synthesized mouse
-- press is the ONLY event the launcher gets. Filtering istouch on both
-- killed every tap on iOS outright.
if istouch and love.system.getOS() == "Android" then return end
return Importer:mousepressed(x, y, button)
end
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
end
if Game and (mouseTouch or Game:hasPointerScreen()) and button == 1 and not istouch then
Game:touchpressed("mouse", x, y)
end
end
function love.mousereleased(x, y, button, istouch)
if TouchEditor then
if love.system.getOS() == "Android" then return end
return TouchEditor.mousereleased(x, y, button)
end
if Importer then return end
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
end
if mouseTouch then
if Game and button == 1 then pcall(function() Game:touchreleased("mouse", x, y) end) end
return
end
if Game and Game.mousereleased and not istouch then
Game:mousereleased(x, y, button, istouch)
end
if Game and Game.recenterLookPointer and not istouch then
Game:recenterLookPointer()
end
end
function love.mousemoved(x, y, dx, dy, istouch)
-- FREE-CAMERA LOOK FIRST, and only when one is up: `Game:cameraLook`
-- answers false on every other map and camera, so the touch path below
-- keeps every case it had. Warp back to centre after a real look so the
-- pointer never sits on the window edge (where dx/dy become zero).
if Game and Game.lookWarping then
Game.lookWarping = false
return
end
if Game and not istouch and not (editorMode or Importer or TouchEditor)
and not Game:hasPointerScreen()
and Game.cameraLook and Game:cameraLook(dx, dy) then
if ((dx or 0) ~= 0 or (dy or 0) ~= 0) and Game.recenterLookPointer then
Game:recenterLookPointer()
end
return
end
if TouchEditor then
if love.system.getOS() == "Android" then return end
return TouchEditor.mousemoved(x, y)
end
if editorMode or Importer then return end
if Game and not istouch and (mouseTouch or Game:hasPointerScreen()) and love.mouse.isDown(1) then
Game:touchmoved("mouse", x, y)