@frotzinc/zcode-ui 0.1.0 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to `@frotzinc/zcode-ui`. Versions follow [Semantic Versioning](https://semver.org/). Part of [frotz, Inc.](https://frotzinc.com).
4
4
 
5
+ ## [0.2.0] - 2026-11-08
6
+
7
+ The views light a running machine (frotztrace Phase 4). Without `live` or `breakpoints`, each renders exactly as before.
8
+
9
+ ### Added
10
+ - **`Disassembly`**: with `live`, the PC's line is marked (with "current instruction" for screen readers); with `breakpoints`, a gutter button per line (`aria-pressed`) emits `toggleBreakpoint`.
11
+ - **`GlobalsView`**: with `live`, a "Now" column read from the running machine's memory, and the globals changed since the last stop marked "changed".
12
+ - **`MapView`**: with `live`, the player's room (`real_location`, or `location`) is marked and the visited rooms shaded, with "you are here" and "visited" in the list.
13
+ - **`useSymbols().symbolsText`**: the debug file's text as read, so frotzlens can hand it to frotztrace.
14
+ - **`ObjectTree`** and **`HexPane`**: with `live`, what changed is lit, with a "changed" badge or count as its text equivalent. Give `ObjectTree` a story whose objects are read live (`zcode`'s `liveTables`) to show the tree as it is now.
15
+
16
+ ## [0.1.1] - 2026-11-08
17
+
18
+ ### Changed
19
+ - `MachineView` is now the Debugger's own type from `@frotzinc/zmachine` (0.2.0), re-exported; the same shape as the placeholder it replaces. `@frotzinc/zmachine` is a new dependency, for the type.
20
+
5
21
  ## [0.1.0] - 2026-11-08
6
22
 
7
23
  The first release: frotzlens's story views, moved out of the app (and relicensed MIT by their author) so frotztrace can show a running story with the same components.
@@ -7,7 +7,11 @@ import type { MachineView } from '../utils/live'
7
7
  * it uses is a link, and a branch or jump target moves focus to its line. `live` is unused until
8
8
  * frotztrace's Phase 4, when it marks the program counter.
9
9
  */
10
- const props = defineProps<{ view: RoutineView; address: number; live?: MachineView }>()
10
+ const props = defineProps<{ view: RoutineView; address: number; live?: MachineView; breakpoints?: ReadonlySet<number> }>()
11
+ /** frotztrace: a line's gutter button was pressed. */
12
+ const emit = defineEmits<{ toggleBreakpoint: [address: number] }>()
13
+ /** The gutter (the PC, breakpoints) only when there's something to show: frotzlens's view is unchanged. */
14
+ const gutter = computed(() => !!props.live || !!props.breakpoints)
11
15
 
12
16
  // Labels: a branch or jump target scrolls to its line and focuses it (the route stays the routine).
13
17
  function jump(target: number) {
@@ -29,7 +33,16 @@ function jump(target: number) {
29
33
  :key="l.address"
30
34
  tabindex="-1"
31
35
  class="whitespace-pre rounded focus:bg-elevated"
32
- ><span class="text-muted">{{ `${hex(l.address).padStart(5)} ` }}</span><span class="text-lamp">{{ (l.label ? `${l.label}:` : '').padEnd(7) }}</span><template v-for="(p, j) in l.pieces" :key="j"><a v-if="p.id" :href="idRoute(p.id)" class="text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid">{{ p.text }}</a><a v-else-if="p.target !== undefined" :href="`#routine/${hex(address)}`" class="text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid" :aria-label="`${p.text}, the instruction at ${hex(p.target)}`" @click.prevent="jump(p.target)">{{ p.text }}</a><span v-else>{{ p.text }}</span></template></div>
36
+ :class="live?.pc === l.address ? 'bg-elevated font-semibold' : ''"
37
+ ><span v-if="gutter" class="inline-flex w-12 items-center gap-1"><button
38
+ v-if="breakpoints"
39
+ type="button"
40
+ class="size-4 rounded-full border border-default leading-none"
41
+ :class="breakpoints.has(l.address) ? 'bg-[var(--frotz-lamp)]' : 'hover:bg-elevated'"
42
+ :aria-pressed="breakpoints.has(l.address)"
43
+ :aria-label="`Breakpoint at ${hex(l.address)}`"
44
+ @click="emit('toggleBreakpoint', l.address)"
45
+ /><span v-if="live?.pc === l.address" class="text-lamp" aria-hidden="true">▶</span><span v-if="live?.pc === l.address" class="sr-only">current instruction</span></span><span class="text-muted">{{ `${hex(l.address).padStart(5)} ` }}</span><span class="text-lamp">{{ (l.label ? `${l.label}:` : '').padEnd(7) }}</span><template v-for="(p, j) in l.pieces" :key="j"><a v-if="p.id" :href="idRoute(p.id)" class="text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid">{{ p.text }}</a><a v-else-if="p.target !== undefined" :href="`#routine/${hex(address)}`" class="text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid" :aria-label="`${p.text}, the instruction at ${hex(p.target)}`" @click.prevent="jump(p.target)">{{ p.text }}</a><span v-else>{{ p.text }}</span></template></div>
33
46
  </div>
34
47
  </div>
35
48
  </template>
@@ -9,12 +9,15 @@ const number = computed(() => (props.id && /^\d+$/.test(props.id) ? Number(props
9
9
  const global = computed(() => (number.value === undefined ? undefined : props.story.globals[number.value]))
10
10
 
11
11
  const filter = ref('')
12
+ /** Live (frotztrace): the value now, from the running machine's memory. */
13
+ const nowOf = (address: number) => (props.live ? (props.live.memory[address]! << 8) | props.live.memory[address + 1]! : undefined)
14
+
12
15
  const rows = computed(() => {
13
16
  const s = props.story
14
17
  const q = filter.value.trim().toLowerCase()
15
18
  return s.globals
16
19
  // Uses: each place an instruction reads or writes it, as the detail's "Used by" counts them.
17
- .map(g => ({ g, name: globalName(g.number), txd: `G${hex(g.number, 2)}`, uses: s.xrefs[g.id]?.from.length ?? 0 }))
20
+ .map(g => ({ g, name: globalName(g.number), txd: `G${hex(g.number, 2)}`, uses: s.xrefs[g.id]?.from.length ?? 0, now: nowOf(g.address), changed: !!props.live?.changed.globals.has(g.number) }))
18
21
  .filter(r => !q || r.name.toLowerCase().includes(q) || r.txd.toLowerCase() === q || hex(r.g.value).includes(q.replace(/^#|0x/, '')))
19
22
  })
20
23
 
@@ -41,15 +44,20 @@ const asText = () =>
41
44
  <th v-if="activeSymbols" scope="col" class="px-4 py-2 font-semibold">Number</th>
42
45
  <th scope="col" class="px-4 py-2 font-semibold">Address</th>
43
46
  <th scope="col" class="px-4 py-2 font-semibold">Starts as</th>
47
+ <th v-if="live" scope="col" class="px-4 py-2 font-semibold">Now</th>
44
48
  <th scope="col" class="px-4 py-2 text-right font-semibold">Used by</th>
45
49
  </tr>
46
50
  </thead>
47
51
  <tbody>
48
- <tr v-for="r in rows" :key="r.g.number" class="border-t border-default">
52
+ <tr v-for="r in rows" :key="r.g.number" class="border-t border-default" :class="r.changed ? 'bg-elevated' : ''">
49
53
  <td class="data px-4 py-1.5"><a :href="`#global/${r.g.number}`" class="text-lamp hover:underline">{{ r.name }}</a></td>
50
54
  <td v-if="activeSymbols" class="data px-4 py-1.5 text-muted">{{ r.txd }}</td>
51
55
  <td class="data px-4 py-1.5 text-muted">{{ hex(r.g.address) }}</td>
52
56
  <td class="data px-4 py-1.5">{{ hex(r.g.value) }} <span class="text-muted">({{ r.g.value }})</span></td>
57
+ <td v-if="live" class="data px-4 py-1.5" :class="r.changed ? 'font-semibold text-lamp' : ''">
58
+ {{ hex(r.now!) }} <span class="text-muted">({{ r.now }})</span>
59
+ <span v-if="r.changed" class="ml-2 rounded-full border border-default px-1.5 font-sans text-xs text-lamp">changed</span>
60
+ </td>
53
61
  <td class="data px-4 py-1.5 text-right">{{ r.uses }}</td>
54
62
  </tr>
55
63
  </tbody>
@@ -4,8 +4,8 @@ import type { MachineView } from '../utils/live'
4
4
  /**
5
5
  * A hex view of bytes, a page of rows at a time so a large file stays quick, with "Go to address"
6
6
  * and paging. `top` (v-model) is the first row's address. `notes` labels what begins in a row;
7
- * `inRange` dims the bytes outside the part being shown. `live` is unused until frotztrace's
8
- * Phase 4, when it lights the bytes a running machine changed.
7
+ * `inRange` dims the bytes outside the part being shown. `live` (frotztrace) lights the bytes the
8
+ * running machine changed since it last stopped; pass the live memory as `bytes`.
9
9
  */
10
10
  const props = defineProps<{
11
11
  bytes: Uint8Array
@@ -38,6 +38,8 @@ const rows = computed(() => {
38
38
  return out
39
39
  })
40
40
  const inPart = (a: number) => props.inRange?.(a) ?? true
41
+ /** Live: how many of the row's bytes changed (the text equivalent of their highlight). */
42
+ const changedIn = (at: number) => { let n = 0; for (let i = 0; i < 16; i++) if (props.live?.changed.addresses.has(at + i)) n++; return n }
41
43
 
42
44
  const jump = ref('')
43
45
  const jumpError = ref('')
@@ -82,9 +84,9 @@ function onJump() {
82
84
  <tbody>
83
85
  <tr v-for="r in rows" :key="r.at">
84
86
  <th scope="row" class="px-4 text-left font-normal text-muted">{{ hex(r.at, 5) }}</th>
85
- <td class="whitespace-pre px-2"><template v-for="(b, i) in r.bytes" :key="i"><span :class="inPart(r.at + i) ? 'text-highlighted' : 'text-muted'">{{ hex(b, 2) }}</span>{{ i === 7 ? ' ' : i < 15 ? ' ' : '' }}</template></td>
87
+ <td class="whitespace-pre px-2"><template v-for="(b, i) in r.bytes" :key="i"><span :class="live?.changed.addresses.has(r.at + i) ? 'rounded bg-[var(--frotz-lamp)] text-[var(--frotz-ground)]' : inPart(r.at + i) ? 'text-highlighted' : 'text-muted'">{{ hex(b, 2) }}</span>{{ i === 7 ? ' ' : i < 15 ? ' ' : '' }}</template></td>
86
88
  <td class="whitespace-pre px-4 text-muted">{{ r.text }}</td>
87
- <td class="whitespace-nowrap px-4 font-sans text-xs text-lamp">{{ r.notes.join(' · ') }}</td>
89
+ <td class="whitespace-nowrap px-4 font-sans text-xs text-lamp">{{ [...r.notes, ...(changedIn(r.at) ? [`${changedIn(r.at)} changed`] : [])].join(' · ') }}</td>
88
90
  </tr>
89
91
  </tbody>
90
92
  </table>
@@ -58,6 +58,27 @@ const exitIndex = computed(() => {
58
58
  })
59
59
  const exitsFrom = (n: number) => exitIndex.value.get(n) ?? []
60
60
  const LINK = 'text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid'
61
+
62
+ /**
63
+ * Live (frotztrace): the player's room and the rooms visited (the `visited` attribute), as they are
64
+ * now. The room is `real_location` when the library has it: in the dark, `location` is the
65
+ * darkness object, not a room.
66
+ */
67
+ const here = computed(() => {
68
+ const s = symbols.value
69
+ if (!props.live || !s) return undefined
70
+ const named = (name: string) => Object.entries(s.globals).find(([, n]) => n === name)?.[0]
71
+ const g = named('real_location') ?? named('location')
72
+ if (g === undefined) return undefined
73
+ const a = props.story.header.globals + Number(g) * 2
74
+ return (props.live.memory[a]! << 8) | props.live.memory[a + 1]!
75
+ })
76
+ const visited = computed(() => {
77
+ const s = symbols.value
78
+ const attr = s && Object.entries(s.attributes).find(([, n]) => n === 'visited')?.[0]
79
+ if (!props.live || attr === undefined) return new Set<number>()
80
+ return new Set(props.story.objects.objects.filter(o => o.attributes.includes(Number(attr))).map(o => o.number))
81
+ })
61
82
  const copy = () => JSON.stringify(toMapGraph(graph.value!), null, 2) + '\n'
62
83
  </script>
63
84
 
@@ -109,11 +130,11 @@ const copy = () => JSON.stringify(toMapGraph(graph.value!), null, 2) + '\n'
109
130
  :width="W"
110
131
  :height="H"
111
132
  rx="8"
112
- fill="var(--ui-bg-elevated)"
113
- :stroke="r.number === graph.start ? 'var(--frotz-lamp-text)' : 'var(--ui-border-accented)'"
114
- :stroke-width="r.number === graph.start ? 3 : 1.5"
133
+ :fill="r.number === here ? 'var(--frotz-lamp)' : visited.has(r.number) ? 'var(--ui-bg-accented)' : 'var(--ui-bg-elevated)'"
134
+ :stroke="r.number === here || r.number === graph.start ? 'var(--frotz-lamp-text)' : 'var(--ui-border-accented)'"
135
+ :stroke-width="r.number === here ? 4 : r.number === graph.start ? 3 : 1.5"
115
136
  />
116
- <text :x="r.col * CELL" :y="r.row * CELL + 5" text-anchor="middle" font-size="14" fill="var(--ui-text-highlighted)">{{ short(name(r.number)) }}</text>
137
+ <text :x="r.col * CELL" :y="r.row * CELL + 5" text-anchor="middle" font-size="14" :fill="r.number === here ? 'var(--frotz-ground)' : 'var(--ui-text-highlighted)'">{{ short(name(r.number)) }}</text>
117
138
  </a>
118
139
  </svg>
119
140
  </div>
@@ -124,6 +145,8 @@ const copy = () => JSON.stringify(toMapGraph(graph.value!), null, 2) + '\n'
124
145
  <li v-for="r in graph.rooms" :key="r.number">
125
146
  <a :href="`#object/${r.number}`" class="font-semibold text-lamp hover:underline">{{ name(r.number) }}</a>
126
147
  <span v-if="r.number === graph.start" class="ml-2 text-xs text-muted">where the game starts</span>
148
+ <span v-if="r.number === here" class="ml-2 rounded-full border border-default px-1.5 text-xs text-lamp">you are here</span>
149
+ <span v-else-if="visited.has(r.number)" class="ml-2 text-xs text-muted">visited</span>
127
150
  <ul v-if="exitsFrom(r.number).length" class="mt-1 flex flex-col gap-0.5 pl-4">
128
151
  <li v-for="(e, i) in exitsFrom(r.number)" :key="i">
129
152
  <span class="data inline-block w-24 text-muted">{{ DIRS[e.dir] }}</span>
@@ -169,7 +169,8 @@ function onKey(e: KeyboardEvent) {
169
169
  >
170
170
  <UIcon v-if="hasChildren(r.n)" :name="open.has(r.n) ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'" class="size-4" />
171
171
  </span>
172
- <span class="truncate">{{ name(r.n) }}</span>
172
+ <span class="truncate" :class="live?.changed.objects.has(r.n) ? 'font-semibold text-lamp' : ''">{{ name(r.n) }}</span>
173
+ <span v-if="live?.changed.objects.has(r.n)" class="rounded-full border border-default px-1.5 text-xs text-lamp">changed</span>
173
174
  <span class="data ml-auto pl-2 text-xs text-muted">{{ r.n }}</span>
174
175
  </li>
175
176
  </ul>
@@ -8,6 +8,8 @@ import { MAX_DEBUG_FILE, type StoryFile, type Symbols } from '@frotzinc/zcode'
8
8
  */
9
9
  const symbols = shallowRef<Symbols | null>(null)
10
10
  const symbolsFile = ref('')
11
+ /** The debug file's text as read (frotzlens hands it to frotztrace with the story). */
12
+ const symbolsText = shallowRef('')
11
13
  const symbolsStatus = ref<'none' | 'loading' | 'ready' | 'failed'>('none')
12
14
  const symbolsMessage = ref('')
13
15
  /** The last file chosen was refused (whatever is loaded stays). */
@@ -40,6 +42,7 @@ export function useSymbols() {
40
42
  const match = symbolsMatch(story.bytes, s)
41
43
  if (match === false) throw new Error(`${file.name} was written for another story file, so its names would be wrong here.`)
42
44
  symbols.value = markRaw(s)
45
+ symbolsText.value = text
43
46
  symbolsFile.value = file.name
44
47
  symbolsStatus.value = 'ready'
45
48
  symbolsVerified.value = match === true
@@ -57,6 +60,7 @@ export function useSymbols() {
57
60
  function clearSymbols() {
58
61
  generation++
59
62
  symbols.value = null
63
+ symbolsText.value = ''
60
64
  symbolsFile.value = ''
61
65
  symbolsStatus.value = 'none'
62
66
  symbolsMessage.value = ''
@@ -65,5 +69,5 @@ export function useSymbols() {
65
69
  names.value = true
66
70
  }
67
71
 
68
- return { symbols, symbolsFile, symbolsStatus, symbolsMessage, symbolsError, symbolsVerified, names, loadSymbols, clearSymbols }
72
+ return { symbols, symbolsFile, symbolsText, symbolsStatus, symbolsMessage, symbolsError, symbolsVerified, names, loadSymbols, clearSymbols }
69
73
  }
package/app/utils/live.ts CHANGED
@@ -1,19 +1,7 @@
1
1
  /**
2
- * A running machine, as a view sees it: where it is, and what changed since it last stopped.
3
- *
4
- * A placeholder until frotztrace's Phase 3, when @frotzinc/zmachine exports the real type and this
5
- * file re-exports it. Every view takes it as an optional `live` prop; without one (frotzlens, a
6
- * story file at rest) a view renders exactly as it always has.
2
+ * A running machine, as a view sees it: where it is, and what changed since it last stopped. The
3
+ * Debugger's own type (@frotzinc/zmachine, since frotztrace Phase 3), re-exported here: every view
4
+ * takes it as an optional `live` prop; without one (frotzlens, a story file at rest) a view renders
5
+ * exactly as it always has.
7
6
  */
8
- export interface MachineView {
9
- /** The program counter: the next instruction to run. */
10
- pc?: number
11
- /** Index of the frame being looked at (0 = the outermost). */
12
- frame?: number
13
- /** What the machine wrote since the last stop. */
14
- changed?: {
15
- addresses: ReadonlySet<number>
16
- objects: ReadonlySet<number>
17
- globals: ReadonlySet<number>
18
- }
19
- }
7
+ export type { MachineView } from '@frotzinc/zmachine'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frotzinc/zcode-ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The Z-machine story views of frotzlens as a Nuxt layer: object tree, globals, map, disassembly, hex, go-to palette and the pieces they share, for frotzlens and frotztrace.",
5
5
  "license": "MIT",
6
6
  "author": "Christopher Schweda",
@@ -38,7 +38,8 @@
38
38
  "LICENSE"
39
39
  ],
40
40
  "dependencies": {
41
- "@frotzinc/zcode": "0.4.1"
41
+ "@frotzinc/zcode": "0.5.0",
42
+ "@frotzinc/zmachine": "0.2.0"
42
43
  },
43
44
  "peerDependencies": {
44
45
  "@nuxt/ui": "^4.12.0",