@wasm-gaming/fbneo-wasm 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +133 -76
  2. package/dist/fbneo/app/app.css +112 -0
  3. package/dist/fbneo/app/app.js +646 -0
  4. package/dist/fbneo/engine-specs.js +646 -0
  5. package/dist/fbneo/fbneo.js +2 -0
  6. package/dist/fbneo/fbneo.options.d.ts +66 -19
  7. package/dist/fbneo/fbneo.options.d.ts.map +1 -1
  8. package/dist/fbneo/fbneo.options.js +39 -44
  9. package/dist/fbneo/fbneo.options.js.map +1 -1
  10. package/dist/fbneo/fbneo.sdk.d.ts +137 -8
  11. package/dist/fbneo/fbneo.sdk.d.ts.map +1 -1
  12. package/dist/fbneo/fbneo.sdk.js +588 -184
  13. package/dist/fbneo/fbneo.sdk.js.map +1 -1
  14. package/dist/fbneo/fbneo.wasm +4 -0
  15. package/dist/fbneo/neo.d.ts +83 -0
  16. package/dist/fbneo/neo.d.ts.map +1 -0
  17. package/dist/fbneo/neo.js +357 -0
  18. package/dist/fbneo/neo.js.map +1 -0
  19. package/dist/fbneo/romset.d.ts +0 -0
  20. package/dist/fbneo/romset.d.ts.map +1 -1
  21. package/dist/fbneo/romset.js +0 -0
  22. package/dist/fbneo/romset.js.map +1 -1
  23. package/{data → dist/fbneo}/romsets/capcom.json +0 -144
  24. package/{data → dist/fbneo}/romsets/index.json +7 -7
  25. package/{data → dist/fbneo}/romsets/konami.json +0 -8
  26. package/{data → dist/fbneo}/romsets/megadrive.json +0 -18
  27. package/dist/fbneo/romsets/neogeo.json +39274 -0
  28. package/{data → dist/fbneo}/romsets/nes.json +0 -81
  29. package/{data → dist/fbneo}/romsets/pgm.json +0 -73
  30. package/{data → dist/fbneo}/romsets/pre90s.json +396 -273
  31. package/{data → dist/fbneo}/romsets/pst90s.json +0 -152
  32. package/{data → dist/fbneo}/romsets/sega.json +0 -175
  33. package/{data → dist/fbneo}/romsets/sms.json +0 -9
  34. package/{data → dist/fbneo}/romsets/taito.json +0 -24
  35. package/{data → dist/fbneo}/romsets/toaplan.json +0 -60
  36. package/package.json +15 -15
  37. package/src/app/app.css +112 -0
  38. package/src/app/app.js +646 -0
  39. package/src/fbneo.options.ts +107 -63
  40. package/src/fbneo.sdk.ts +644 -212
  41. package/src/neo.ts +463 -0
  42. package/src/romset.ts +0 -0
  43. package/data/romsets/neogeo.json +0 -28260
  44. package/dist/fbneo/fbneo.manifest.d.ts +0 -4
  45. package/dist/fbneo/fbneo.manifest.d.ts.map +0 -1
  46. package/dist/fbneo/fbneo.manifest.js +0 -33
  47. package/dist/fbneo/fbneo.manifest.js.map +0 -1
  48. package/dist/manifest.json +0 -91
  49. package/src/fbneo.manifest.ts +0 -38
  50. /package/{data → dist/fbneo}/romsets/atari.json +0 -0
  51. /package/{data → dist/fbneo}/romsets/cave.json +0 -0
  52. /package/{data → dist/fbneo}/romsets/channelf.json +0 -0
  53. /package/{data → dist/fbneo}/romsets/coleco.json +0 -0
  54. /package/{data → dist/fbneo}/romsets/cps3.json +0 -0
  55. /package/{data → dist/fbneo}/romsets/dataeast.json +0 -0
  56. /package/{data → dist/fbneo}/romsets/galaxian.json +0 -0
  57. /package/{data → dist/fbneo}/romsets/irem.json +0 -0
  58. /package/{data → dist/fbneo}/romsets/midway.json +0 -0
  59. /package/{data → dist/fbneo}/romsets/msx.json +0 -0
  60. /package/{data → dist/fbneo}/romsets/pce.json +0 -0
  61. /package/{data → dist/fbneo}/romsets/pgm2.json +0 -0
  62. /package/{data → dist/fbneo}/romsets/psikyo.json +0 -0
  63. /package/{data → dist/fbneo}/romsets/sg1000.json +0 -0
  64. /package/{data → dist/fbneo}/romsets/snes.json +0 -0
  65. /package/{data → dist/fbneo}/romsets/spectrum.json +0 -0
package/README.md CHANGED
@@ -1,44 +1,82 @@
1
1
  # @wasm-gaming/fbneo-wasm
2
2
 
3
- FinalBurn Neo (FBNeo) — the multi-system arcade emulator (Capcom CPS-1/2/3, SNK
4
- Neo Geo, Sega System 16, Cave, PGM, and many more) — compiled to WebAssembly via
5
- Emscripten and packaged as a wasm-gaming engine SDK.
3
+ FinalBurn Neo (FBNeo), the multi-system arcade emulator (Capcom CPS-1/2/3, SNK Neo Geo,
4
+ Sega System 16, Cave, PGM and many more), compiled to WebAssembly and wrapped in a small JS
5
+ SDK that implements the [wasm-gaming engine contract](https://github.com/wasm-gaming/engine-specs)
6
+ 1.0. It ships no games: the player brings the ROM sets, and they never leave the browser.
6
7
 
7
- This subproject follows the same engine-package approach used by jgenesis-wasm,
8
- blastem-wasm, rsdkv3, and rsdkv4:
8
+ ## Install
9
9
 
10
- - typed `manifest`
11
- - typed `options`
12
- - `load(config)` engine SDK surface
13
- - Makefile-driven build (`build-sdk`, `build-wasm`, `preview`)
10
+ ```sh
11
+ npm install @wasm-gaming/fbneo-wasm
12
+ ```
13
+
14
+ The SDK is self-contained: the contract's runtime ships beside it in `dist/fbneo/` and is
15
+ imported relatively, so it loads from any static server with no bundler and no import map.
16
+ The wasm and its glue sit next to it (`fbneo.js`, `fbneo.wasm`).
17
+
18
+ ## Use
19
+
20
+ ```js
21
+ import FbneoSDK from '@wasm-gaming/fbneo-wasm';
22
+
23
+ FbneoSDK.id; // 'fbneo' — also this engine's folder in OPFS
24
+
25
+ const play = await new FbneoSDK()
26
+ .options({ driver: 'mslug' }) // or romFileName: 'mslug.zip'
27
+ .assets({ rom: () => zip.arrayBuffer(), bios: () => neogeo.arrayBuffer() })
28
+ .storage({ namespace: 'mslug' }) // → OPFS fbneo/mslug/
29
+ .on('saves', ({ detail }) => console.log('saved', detail.files))
30
+ .mount(document.querySelector('#stage')) // a <canvas> is used as is; anything else gets one
31
+ .start();
32
+
33
+ play.pause('menu'); // the first caller owns the pause…
34
+ play.resume('menu'); // …and only it lifts it; sound stops too
35
+ await play.restart(); // soft reset
36
+ await play.states.save(0); // save states: slots 0-3, plus 9 (the suspend point)
37
+ play.states.load(0);
38
+ await play.destroy(); // saves flushed, loop stopped, audio closed
39
+ ```
40
+
41
+ | payload | keys |
42
+ | --- | --- |
43
+ | `config` | `renderFilter` (`pixelated` \| `smooth`, live), `jsUrl`, `wasmUrl` |
44
+ | `options` | `driver` (FBNeo short name), `romFileName` (its stem is the driver when `driver` is empty) |
45
+ | `assets` | `rom` (`.zip`/`.7z`, required), `bios` (`.zip`, e.g. `neogeo.zip`) |
46
+ | `storage` | `namespace` (default `default`), `persist` (`'opfs'` \| `null`) |
47
+
48
+ FBNeo boots a game by its ROM-set short name (the *driver*, `mslug`) and finds `<driver>.zip`
49
+ in its ROM path; the SDK writes the bytes there under that name, so the player's file name
50
+ never matters once the driver is known.
14
51
 
15
- It conforms to the [`@wasm-gaming/engine-specs`](https://github.com/wasm-gaming/engine-specs)
16
- contract (`EngineSDK` = `{ manifest, load }`).
52
+ Events: the contract's `start`, `error`, `exit`, `pause`, `resume`, plus `saves`.
17
53
 
18
- ## Contract surface
54
+ ## The engine's own app
55
+
56
+ A host that would rather not draw menus mounts the app shipped next to the SDK
57
+ (engine-specs `EngineApp`, `UX.md`):
19
58
 
20
59
  ```js
21
- import { manifest, load } from '@wasm-gaming/fbneo-wasm';
22
-
23
- const engine = await load({
24
- canvas, // per engine-specs EngineConfig
25
- assets: {
26
- rom: romZipBytes, // arcade ROM set (zip)
27
- bios: neogeoZipBytes, // optional: e.g. neogeo.zip for Neo Geo sets
28
- },
29
- options: { driver: 'mslug', renderFilter: 'pixelated' },
30
- onEvent: (e) => console.log(e),
31
- });
32
- engine.start();
60
+ const app = await import('@wasm-gaming/fbneo-wasm/app'); // dist/fbneo/app/app.js
61
+ const handle = await app.mount(element, { game, storage, assets, input, exit });
62
+ await handle.unmount();
33
63
  ```
34
64
 
35
- FBNeo identifies a game by the ROM zip **filename** (without extension). Pass the
36
- game short name explicitly via `options.driver` (e.g. `"mslug"`, `"sf2"`,
37
- `"kof98"`), or let it be inferred from `options.romFileName` / the picked file's
38
- name. (The bytes are always written to MEMFS as `<driver>.zip`, so the source
39
- file itself never needs renaming — only its *name* has to resolve to a driver.)
65
+ The same app as blastem-wasm's: suspend points the way the Nintendo Classic Mini does them
66
+ (leaving keeps an unsaved point with its frame; coming back offers it, four slots and "from
67
+ the beginning"), and a pause menu on Select + Start or Escape (*Continuar*, *Puntos de
68
+ suspensión*, *Reiniciar*, *Volver al lanzador*). The host hands over bytes, not a driver
69
+ name, so the app identifies the set by its contents (the datasets below, Neo Geo and Capcom
70
+ first), rebuilds a `.neo` image, and falls back to `game.slug`.
71
+
72
+ ## Saves live in the engine's own folder
73
+
74
+ FBNeo's NVRAM/EEPROM (`<driver>.fs`), hiscores (`<driver>.hi`) and save states
75
+ (`slot_N.fs`, with an optional `slot_N.png` frame and `slot_N.lock`) are written to MEMFS
76
+ `/save`, which the SDK seeds from OPFS `fbneo/<namespace>/` before boot and mirrors back
77
+ every 10 s, on pause, when the page is hidden and on destroy. `persist: null` keeps nothing.
40
78
 
41
- ### Identifying a ROM set from its contents
79
+ ## Identifying a ROM set from its contents
42
80
 
43
81
  When the filename is unreliable (a browser-mangled `mslug (1).zip`) but the ROM
44
82
  files inside must keep their canonical names, recover the driver from the zip's
@@ -55,16 +93,21 @@ const neogeo = await fetch('/romsets/neogeo.json').then((r) => r.json());
55
93
  const index = new RomsetIndex([neogeo]);
56
94
 
57
95
  const match = resolveDriver(romZipBytes, index); // { driver: 'mslug', coverage: 1, ... } | null
58
- const engine = await load({
59
- canvas,
60
- assets: { rom: romZipBytes },
61
- options: { driver: match?.driver }, // fall back to your own logic when null
62
- });
96
+ const play = await new FbneoSDK()
97
+ .options({ driver: match?.driver ?? '' }) // fall back to your own logic when null
98
+ .assets({ rom: romZipBytes })
99
+ .mount(stage)
100
+ .start();
63
101
  ```
64
102
 
65
- The dataset ships as `data/romsets/<system>.json` (also served at `/romsets/…`
66
- from the built `dist/`); `data/romsets/index.json` lists the systems and game
67
- counts. Regenerate it from a pinned FBNeo checkout with `make romsets`.
103
+ The dataset is `data/romsets/<system>.json` in the repo and ships next to the SDK as
104
+ `fbneo/romsets/<system>.json` (package export `./romsets/*`); `index.json` lists the
105
+ systems and game counts. Regenerate it from a pinned FBNeo checkout with `make romsets`.
106
+
107
+ Each ROM entry carries its internal name (`n`) and CRC-32 (`c`). `neogeo.json`
108
+ additionally carries each chip's size (`s`) and hardware region (`r`) — the chip
109
+ *layout* — which is what lets the `neo` helper below rebuild a ROM set from a
110
+ flash-cart image.
68
111
 
69
112
  Because the match is by CRC-32 — and FBNeo itself looks ROM chips up by CRC
70
113
  before falling back to their names — a set is usually accepted as downloaded:
@@ -72,60 +115,74 @@ extra files, subdirectories, and non-canonical chip filenames inside the zip do
72
115
  not matter. Only the zip's *name* has to resolve to a driver, which is exactly
73
116
  what this helper supplies.
74
117
 
75
- ### Neo Geo `.neo` files (NeoSD / Terraonion)
118
+ ## Neo Geo `.neo` files (NeoSD / Terraonion)
76
119
 
77
120
  FBNeo reads `.zip` and `.7z` ROM sets only. A `.neo` — the single-file format for
78
121
  NeoSD/Terraonion flash carts, and how most Neo Geo homebrew is distributed — is a
79
122
  4 KiB header followed by each ROM region as one contiguous blob (with the sprite
80
123
  region byte-interleaved as the cartridge wires it), so it cannot be handed to the
81
- engine as-is. Convert it to a bootable ROM set first:
124
+ engine as-is. The opt-in `@wasm-gaming/fbneo-wasm/neo` helper rebuilds the chip
125
+ split FBNeo expects, in the browser and with no dependencies:
126
+
127
+ ```js
128
+ import { isNeoFile, neoToRomset } from '@wasm-gaming/fbneo-wasm/neo';
129
+
130
+ const neogeo = await fetch('/romsets/neogeo.json').then((r) => r.json());
131
+
132
+ if (isNeoFile(bytes)) {
133
+ const { driver, zip } = neoToRomset(bytes, neogeo); // throws if it cannot be rebuilt
134
+ await new FbneoSDK().options({ driver }).assets({ rom: zip }).mount(stage).start();
135
+ }
136
+ ```
137
+
138
+ It identifies the driver by matching the `.neo`'s region sizes against the chip
139
+ layouts in `data/romsets/neogeo.json`, rebuilds the individual chip dumps
140
+ (de-interleaving sprites, undoing the `HARDWARE_SNK_SWAPP` program-ROM swap), and
141
+ CRC-32 verifies every chip before returning — so it throws loudly rather than
142
+ handing the engine a broken set. The zip it returns is *stored*, not deflated:
143
+ FBNeo decompresses each chip immediately, so compressing would only be undone.
144
+
145
+ The same conversion is available offline, which is handy for preparing a set once
146
+ rather than on every page load:
82
147
 
83
148
  ```sh
84
- node scripts/neo2romset.mjs mygame.neo --out ./roms
149
+ node scripts/neo2romset.mjs mygame.neo --out ./roms # needs `make build-lib`
85
150
  ```
86
151
 
87
- It identifies the driver by matching the `.neo`'s region sizes against FBNeo's own
88
- chip tables, rebuilds the individual chip dumps (de-interleaving sprites, undoing
89
- the `HARDWARE_SNK_SWAPP` program-ROM swap), and CRC-verifies every chip before
90
- writing `<driver>.zip` — so it fails loudly rather than emitting a broken set. It
91
- reads the chip tables from a FBNeo checkout (`.tmp/fbneo-build/…` by default, as
92
- left by `make build-wasm-ci`; override with `--src`).
93
-
94
- ### Options
95
-
96
- | Option | Default | Description |
97
- |--------|---------|-------------|
98
- | `romFileName` | `game.zip` | Filename used when writing ROM bytes to MEMFS. |
99
- | `driver` | *(inferred)* | FBNeo ROM set short name to boot. |
100
- | `renderFilter` | `pixelated` | `pixelated` (crisp) or `smooth` (linear). |
101
- | `audioSampleRate` | `44100` | Target audio sample rate in Hz. |
102
- | `vsync` | `true` | Enable vertical sync. |
103
- | `integerScale` | `false` | Integer scaling only. |
104
- | `windowScale` | `2` | Internal render-resolution multiplier. |
152
+ Sets whose program ROM lives inside the SMA custom chip cannot be reproduced by a
153
+ straight carve; those fail CRC verification and are reported rather than written.
105
154
 
106
155
  ## Build
107
156
 
108
157
  ```sh
109
- make build # Full build: WASM (Docker/Emscripten) + TypeScript SDK
110
- make build-sdk # TypeScript only (SDK + manifest + demo shell)
111
- make build-wasm # FBNeo WASM only (via Docker)
112
- make preview # Serve dist/ with COOP/COEP headers
158
+ make build-sdk # TypeScript → dist/fbneo/ (no WASM), with the app and the datasets
159
+ make build-wasm # FBNeo → WebAssembly in the pinned emscripten/emsdk:6.0.2 (Docker)
160
+ make demo # the no-build demo page → dist/index.html
161
+ make test # type-check, then the tests against the built package
162
+ make preview # serve dist/ with COOP/COEP
163
+ make romsets # regenerate data/romsets/*.json from the pinned FBNeo checkout
113
164
  ```
114
165
 
115
- WebAssembly threads/SharedArrayBuffer require the COOP/COEP headers that
116
- `make preview` sets — serve the built `dist/` with those headers in production too.
166
+ `scripts/build-fbneo.sh` fetches FBNeo at a pinned commit and ports its SDL2 burner to
167
+ Emscripten: every source patch checks it applied, so a moved upstream fails the build
168
+ instead of producing a broken engine. FBNeo's own blocking loop runs under ASYNCIFY and
169
+ paces itself to the driver's frame rate on the real-time clock (not the monitor's refresh);
170
+ `scripts/web_bridge.cpp` adds the SDK's handles (pause, reset, NVRAM flush, save states).
171
+
172
+ ## Project Specs
173
+
174
+ - Contract: engine-specs 1.0 (`FbneoSDK` / `FbneoPlay`), self-contained ESM.
175
+ - Engine: FBNeo pinned (see `FBNEO_REV` in `scripts/build-fbneo.sh`), Emscripten 6.0.2, ASYNCIFY.
176
+ - Storage: OPFS `fbneo/<namespace>/` (NVRAM, hiscores, save states).
177
+ - App: `dist/fbneo/app/app.js` (EngineApp), driver identified by content.
178
+ - Records: [RECORD/](RECORD/).
117
179
 
118
- ## WASM artifacts
180
+ ## Todos
119
181
 
120
- | File | Description |
121
- |------|-------------|
122
- | `fbneo.js` | Emscripten-generated module loader (`createFbneoModule`). |
123
- | `fbneo.wasm` | Compiled FBNeo runtime. |
182
+ - Verify game-level behaviour with real ROM sets (pause, reset, states, NVRAM round trip).
183
+ - Move the FBNeo pin forward (later upstream changed the audio code the build patches).
124
184
 
125
- FBNeo uses Emscripten SDL2. The JS loader expects a `<canvas id="canvas">` in the
126
- DOM (the SDK sets this id automatically). ROM zips are written to the in-memory
127
- MEMFS under `/roms/` before the emulator boots, and FBNeo is launched with the
128
- driver short name.
185
+ ## License
129
186
 
130
- See [CORE.md](CORE.md) for the mapping between upstream FBNeo capabilities and the
131
- options currently exposed by this wrapper.
187
+ MIT for this wrapper. FBNeo has its own non-commercial license; ROM sets and BIOS images
188
+ belong to their owners and are never shipped.
@@ -0,0 +1,112 @@
1
+ /* FBNeo app — suspend points (Classic Mini style) and the pause menu.
2
+ engine-specs `--wg-*` tokens when the host sets them; same values as fallbacks. */
3
+
4
+ .fbn-app {
5
+ --ring: var(--wg-focus-ring, 0 0 0 3px #fff);
6
+ --surface: var(--wg-surface, rgb(18 20 28 / 0.78));
7
+ --line: var(--wg-line, rgb(255 255 255 / 0.12));
8
+ --text: var(--wg-text, #f4f6fb);
9
+ --muted: var(--wg-muted, #8e97a9);
10
+ --danger: var(--wg-danger, #f87171);
11
+ --ui: var(--wg-font-ui, 'Inter Variable', 'Inter', system-ui, sans-serif);
12
+ --display: var(--wg-font-display, 'Unbounded Variable', 'Unbounded', system-ui, sans-serif);
13
+ --ease: var(--wg-ease, cubic-bezier(0.2, 0.8, 0.2, 1));
14
+ position: absolute; inset: 0; overflow: hidden; background: #000; color: var(--text);
15
+ font-family: var(--ui); user-select: none; -webkit-tap-highlight-color: transparent;
16
+ }
17
+ .fbn-app * { box-sizing: border-box; }
18
+ .fbn-stage { position: absolute; inset: 0; display: grid; place-items: center; }
19
+ .fbn-stage canvas { outline: none; }
20
+ .fbn-overlay { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; }
21
+ .fbn-overlay > * { pointer-events: auto; }
22
+ .fbn-app:not([data-screen='game']) .fbn-overlay {
23
+ background:
24
+ radial-gradient(120% 80% at 50% 0%, color-mix(in srgb, var(--accent) 34%, transparent), transparent 70%),
25
+ rgb(4 6 12 / 0.74);
26
+ backdrop-filter: blur(14px) saturate(1.2);
27
+ -webkit-backdrop-filter: blur(14px) saturate(1.2);
28
+ }
29
+ .fbn-app[data-screen='boot'] .fbn-overlay,
30
+ .fbn-app[data-screen='points'] .fbn-overlay { background-color: rgb(4 6 12 / 0.9); }
31
+
32
+ .kicker { margin: 0; font: 600 0.72rem var(--ui); letter-spacing: 0.22em; text-transform: uppercase; color: var(--muted); }
33
+ .fbn-app h2 { margin: 0; font: 700 clamp(1.3rem, 3vw, 2rem) var(--display); letter-spacing: -0.02em; }
34
+
35
+ /* boot */
36
+ .fbn-boot { display: grid; justify-items: center; gap: 22px; }
37
+ .fbn-boot img { width: min(240px, 46vw); border-radius: 22px; box-shadow: 0 30px 80px -30px #000; animation: fbn-breathe 2.4s var(--ease) infinite alternate; }
38
+ .fbn-boot p { margin: 0; color: var(--muted); font-weight: 500; }
39
+ @keyframes fbn-breathe { from { opacity: 0.55; transform: scale(0.98); } to { opacity: 1; transform: none; } }
40
+
41
+ /* suspend points: a row of frames, like the Classic Mini's */
42
+ .fbn-points { width: min(1180px, 100%); padding: 0 clamp(16px, 4vw, 48px); display: grid; gap: clamp(20px, 4vh, 36px); }
43
+ .fbn-points header { display: grid; gap: 6px; }
44
+ .points { display: flex; justify-content: center; gap: clamp(10px, 1.4vw, 18px); padding: 12px 4px; }
45
+ .point {
46
+ all: unset; box-sizing: border-box; flex: 0 1 196px; min-width: 0; display: grid; gap: 8px; cursor: pointer;
47
+ transition: transform 0.22s var(--ease), opacity 0.2s;
48
+ }
49
+ .point-frame {
50
+ position: relative; display: grid; place-items: center; aspect-ratio: 320 / 224; border-radius: 10px; overflow: hidden;
51
+ background: rgb(255 255 255 / 0.04); border: 2px solid rgb(255 255 255 / 0.16);
52
+ transition: box-shadow 0.22s var(--ease), border-color 0.2s;
53
+ }
54
+ .point-frame img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; }
55
+ .point-frame svg { width: 30%; color: var(--muted); }
56
+ .point.is-empty .point-frame { border-style: dashed; background: transparent; }
57
+ .point.is-auto .point-frame { border-color: color-mix(in srgb, var(--accent) 70%, #fff); }
58
+ .point-label { font: 700 0.9rem var(--ui); text-align: center; }
59
+ .point.is-auto .point-label { color: #fda4af; }
60
+ .point-date { font: 500 0.74rem var(--ui); color: var(--muted); text-align: center; font-variant-numeric: tabular-nums; }
61
+ .point-lock {
62
+ position: absolute; top: 6px; right: 6px; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%;
63
+ background: rgb(0 0 0 / 0.72); color: #fbbf24;
64
+ }
65
+ .point-lock svg { width: 13px; color: inherit; }
66
+ .point.is-start .point-frame { border-style: solid; }
67
+ .point.is-focused { transform: translateY(-6px) scale(1.04); }
68
+ .point.is-focused .point-frame { box-shadow: var(--ring), 0 26px 60px -24px #000; border-color: transparent; }
69
+ .point.is-dim { opacity: 0.32; }
70
+ .point.is-target.is-focused .point-frame { box-shadow: 0 0 0 3px #fbbf24, 0 26px 60px -24px #000; }
71
+
72
+ /* pause, sheets, confirms */
73
+ .fbn-panel {
74
+ width: min(440px, 92vw); display: grid; gap: 10px; padding: 26px; border-radius: 28px;
75
+ background: rgb(20 22 31 / 0.92); border: 1px solid var(--line); box-shadow: 0 40px 100px -30px #000;
76
+ }
77
+ .fbn-panel.is-small { width: min(380px, 92vw); }
78
+ .fbn-panel h2 { margin-bottom: 8px; }
79
+ .fbn-panel .text { margin: 0 0 8px; color: var(--muted); line-height: 1.5; }
80
+ .item {
81
+ all: unset; box-sizing: border-box; display: grid; gap: 2px; padding: 13px 16px; border-radius: 14px;
82
+ background: rgb(255 255 255 / 0.05); font-weight: 600; cursor: pointer; transition: background 0.15s, box-shadow 0.15s;
83
+ }
84
+ .item small { font-weight: 500; font-size: 0.78rem; color: var(--muted); }
85
+ .item[disabled] { opacity: 0.45; cursor: default; }
86
+ .item.is-focused { background: #fff; color: #0b0d12; box-shadow: var(--ring); }
87
+ .item.is-focused small { color: #4b5563; }
88
+ .item.is-danger.is-focused { background: var(--danger); color: #1a0505; box-shadow: 0 0 0 3px var(--danger); }
89
+ .item.is-danger.is-focused small { color: #450a0a; }
90
+ .fbn-panel .row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
91
+ .fbn-panel .row .item { text-align: center; }
92
+ .fbn-layer { position: absolute; inset: 0; display: grid; place-items: center; background: rgb(0 0 0 / 0.55); }
93
+
94
+ .fbn-hints { display: flex; flex-wrap: wrap; gap: 8px 18px; justify-content: center; color: var(--muted); font-size: 0.78rem; font-weight: 500; }
95
+ .fbn-hints span { display: inline-flex; align-items: center; gap: 7px; }
96
+ .fbn-hints kbd { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 11px; background: rgb(255 255 255 / 0.12); color: var(--text); font: 700 0.68rem var(--ui); }
97
+ .fbn-panel .fbn-hints { justify-content: flex-start; margin-top: 6px; }
98
+
99
+ .fbn-toast {
100
+ position: absolute; left: 50%; bottom: calc(28px + var(--wg-safe-bottom, 0px)); transform: translateX(-50%); margin: 0;
101
+ padding: 10px 18px; border-radius: 999px; background: rgb(20 22 31 / 0.94); border: 1px solid var(--line);
102
+ font-weight: 600; font-size: 0.85rem; white-space: nowrap; animation: fbn-toast 0.3s var(--ease);
103
+ }
104
+ @keyframes fbn-toast { from { opacity: 0; transform: translate(-50%, 8px); } }
105
+
106
+ @media (max-width: 960px) {
107
+ .points { justify-content: flex-start; overflow-x: auto; scrollbar-width: none; padding: 14px 6px 18px; }
108
+ .point { flex: 0 0 min(42vw, 180px); }
109
+ }
110
+ @media (prefers-reduced-motion: reduce) {
111
+ .point, .point-frame, .fbn-boot img, .fbn-toast { transition: none; animation: none; }
112
+ }