@tmustier/pi-nes 0.2.3 → 0.2.4
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/README.md +1 -1
- package/extensions/nes/config.ts +1 -18
- package/extensions/nes/index.ts +1 -21
- package/extensions/nes/nes-component.ts +3 -8
- package/extensions/nes/nes-core.ts +2 -5
- package/extensions/nes/paths.ts +40 -0
- package/extensions/nes/renderer.ts +53 -88
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ The extension uses the **native Rust core** only (required build step). Battery-
|
|
|
88
88
|
|
|
89
89
|
## Rendering
|
|
90
90
|
|
|
91
|
-
Default renderer is `image`, which uses Kitty's image protocol for high resolution. On Kitty, we **prefer shared-memory transport (`t=s`)** when the native addon is built, falling back to the **file transport (`t=f`)** path if the addon isn’t available; non-Kitty terminals fall back to PNG. Image mode runs **nearly full-screen** (no overlay) because Kitty graphics sequences can't be safely composited inside overlays; it caps to ~90% height and centers vertically to reduce terminal compositor load. Image mode also **throttles rendering to ~30fps** to keep emulation speed stable. Set `renderer: "text"` if you prefer ANSI half-block rendering in an overlay. You can tweak `pixelScale` to 1.5–2.0 for larger images in PNG mode.
|
|
91
|
+
Default renderer is `image`, which uses Kitty's image protocol for high resolution. On Kitty, we **prefer shared-memory transport (`t=s`)** when the native addon is built, falling back to the **file transport (`t=f`)** path if the addon isn’t available; non-Kitty terminals fall back to PNG. Image mode runs **nearly full-screen** (no overlay) because Kitty graphics sequences can't be safely composited inside overlays; it caps to ~90% height and centers vertically + horizontally to reduce terminal compositor load. Image mode also **throttles rendering to ~30fps** to keep emulation speed stable. Set `renderer: "text"` if you prefer ANSI half-block rendering in an overlay. You can tweak `pixelScale` to 1.5–2.0 for larger images in PNG mode.
|
|
92
92
|
|
|
93
93
|
## Audio
|
|
94
94
|
|
package/extensions/nes/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { DEFAULT_INPUT_MAPPING, type InputMapping } from "./input-map.js";
|
|
5
|
+
import { normalizePath } from "./paths.js";
|
|
5
6
|
|
|
6
7
|
export type RendererMode = "image" | "text";
|
|
7
8
|
|
|
@@ -90,24 +91,6 @@ function normalizePixelScale(raw: unknown): number {
|
|
|
90
91
|
return Math.min(4, Math.max(0.5, raw));
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
function normalizePath(value: string, fallback: string): string {
|
|
94
|
-
const trimmed = value.trim();
|
|
95
|
-
if (!trimmed) {
|
|
96
|
-
return fallback;
|
|
97
|
-
}
|
|
98
|
-
return expandHomePath(trimmed);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function expandHomePath(value: string): string {
|
|
102
|
-
if (value === "~") {
|
|
103
|
-
return os.homedir();
|
|
104
|
-
}
|
|
105
|
-
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
106
|
-
return path.join(os.homedir(), value.slice(2));
|
|
107
|
-
}
|
|
108
|
-
return value;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
94
|
function normalizeKeybindings(raw: unknown): InputMapping {
|
|
112
95
|
const mapping = cloneMapping(DEFAULT_INPUT_MAPPING);
|
|
113
96
|
if (!raw || typeof raw !== "object") {
|
package/extensions/nes/index.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
5
4
|
import { NesOverlayComponent } from "./nes-component.js";
|
|
@@ -13,6 +12,7 @@ import {
|
|
|
13
12
|
normalizeConfig,
|
|
14
13
|
saveConfig,
|
|
15
14
|
} from "./config.js";
|
|
15
|
+
import { displayPath, resolvePathInput } from "./paths.js";
|
|
16
16
|
import { NesSession } from "./nes-session.js";
|
|
17
17
|
import { listRoms } from "./roms.js";
|
|
18
18
|
import { loadSram } from "./saves.js";
|
|
@@ -77,26 +77,6 @@ function parseArgs(args?: string): { debug: boolean; romArg?: string } {
|
|
|
77
77
|
return { debug: false, romArg: trimmed };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function displayPath(value: string): string {
|
|
81
|
-
const home = os.homedir();
|
|
82
|
-
if (value.startsWith(home)) {
|
|
83
|
-
return `~${value.slice(home.length)}`;
|
|
84
|
-
}
|
|
85
|
-
return value;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function resolvePathInput(input: string, cwd: string): string {
|
|
89
|
-
if (input.startsWith("~")) {
|
|
90
|
-
const trimmed = input.slice(1);
|
|
91
|
-
const suffix = trimmed.startsWith(path.sep) ? trimmed.slice(1) : trimmed;
|
|
92
|
-
return path.join(os.homedir(), suffix);
|
|
93
|
-
}
|
|
94
|
-
if (path.isAbsolute(input)) {
|
|
95
|
-
return input;
|
|
96
|
-
}
|
|
97
|
-
return path.resolve(cwd, input);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
80
|
async function ensureRomDir(pathValue: string, ctx: ExtensionCommandContext): Promise<boolean> {
|
|
101
81
|
try {
|
|
102
82
|
const stat = await fs.stat(pathValue);
|
|
@@ -11,14 +11,9 @@ const FRAME_WIDTH = 256;
|
|
|
11
11
|
const FRAME_HEIGHT = 240;
|
|
12
12
|
|
|
13
13
|
function readRgb(frameBuffer: FrameBuffer, index: number): [number, number, number] {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return [data[base] ?? 0, data[base + 1] ?? 0, data[base + 2] ?? 0];
|
|
18
|
-
}
|
|
19
|
-
const data = frameBuffer.data as ReadonlyArray<number>;
|
|
20
|
-
const color = data[index] ?? 0;
|
|
21
|
-
return [color & 0xff, (color >> 8) & 0xff, (color >> 16) & 0xff];
|
|
14
|
+
const data = frameBuffer.data;
|
|
15
|
+
const base = index * 3;
|
|
16
|
+
return [data[base] ?? 0, data[base + 1] ?? 0, data[base + 2] ?? 0];
|
|
22
17
|
}
|
|
23
18
|
|
|
24
19
|
function renderHalfBlock(
|
|
@@ -4,11 +4,8 @@ const require = createRequire(import.meta.url);
|
|
|
4
4
|
|
|
5
5
|
export type NesButton = "up" | "down" | "left" | "right" | "a" | "b" | "start" | "select";
|
|
6
6
|
|
|
7
|
-
export type FrameBufferFormat = "packed" | "rgb";
|
|
8
|
-
|
|
9
7
|
export interface FrameBuffer {
|
|
10
|
-
|
|
11
|
-
data: ReadonlyArray<number> | Uint8Array;
|
|
8
|
+
data: Uint8Array;
|
|
12
9
|
}
|
|
13
10
|
|
|
14
11
|
export interface NesCore {
|
|
@@ -100,7 +97,7 @@ class NativeNesCore implements NesCore {
|
|
|
100
97
|
}
|
|
101
98
|
|
|
102
99
|
getFrameBuffer(): FrameBuffer {
|
|
103
|
-
return {
|
|
100
|
+
return { data: this.frameBuffer };
|
|
104
101
|
}
|
|
105
102
|
|
|
106
103
|
setButton(button: NesButton, pressed: boolean): void {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function displayPath(value: string): string {
|
|
5
|
+
const home = os.homedir();
|
|
6
|
+
if (value.startsWith(home)) {
|
|
7
|
+
return `~${value.slice(home.length)}`;
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function expandHomePath(value: string): string {
|
|
13
|
+
if (value === "~") {
|
|
14
|
+
return os.homedir();
|
|
15
|
+
}
|
|
16
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
17
|
+
return path.join(os.homedir(), value.slice(2));
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizePath(value: string, fallback: string): string {
|
|
23
|
+
const trimmed = value.trim();
|
|
24
|
+
if (!trimmed) {
|
|
25
|
+
return fallback;
|
|
26
|
+
}
|
|
27
|
+
return expandHomePath(trimmed);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolvePathInput(input: string, cwd: string): string {
|
|
31
|
+
const trimmed = input.trim();
|
|
32
|
+
if (!trimmed) {
|
|
33
|
+
return cwd;
|
|
34
|
+
}
|
|
35
|
+
const expanded = expandHomePath(trimmed);
|
|
36
|
+
if (path.isAbsolute(expanded)) {
|
|
37
|
+
return expanded;
|
|
38
|
+
}
|
|
39
|
+
return path.resolve(cwd, expanded);
|
|
40
|
+
}
|
|
@@ -131,21 +131,8 @@ export class NesImageRenderer {
|
|
|
131
131
|
return null;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
const cell = getCellDimensions();
|
|
137
|
-
const maxWidthByRows = Math.floor(
|
|
138
|
-
(maxRows * cell.heightPx * FRAME_WIDTH) / (FRAME_HEIGHT * cell.widthPx),
|
|
139
|
-
);
|
|
140
|
-
const maxWidthCells = Math.max(1, Math.min(widthCells, maxWidthByRows));
|
|
141
|
-
const maxWidthPx = Math.max(1, maxWidthCells * cell.widthPx);
|
|
142
|
-
const maxHeightPx = Math.max(1, maxRows * cell.heightPx);
|
|
143
|
-
const maxScale = Math.min(maxWidthPx / FRAME_WIDTH, maxHeightPx / FRAME_HEIGHT);
|
|
144
|
-
const requestedScale = Math.max(0.5, pixelScale) * maxScale;
|
|
145
|
-
const scale = Math.min(maxScale, requestedScale);
|
|
146
|
-
const columns = Math.max(1, Math.min(maxWidthCells, Math.floor((FRAME_WIDTH * scale) / cell.widthPx)));
|
|
147
|
-
const rows = Math.max(1, Math.min(maxRows, Math.floor((FRAME_HEIGHT * scale) / cell.heightPx)));
|
|
148
|
-
const padLeft = getHorizontalPadding(widthCells, columns);
|
|
134
|
+
const layout = computeKittyLayout(tui, widthCells, footerRows, pixelScale);
|
|
135
|
+
const { availableRows, columns, rows, padLeft } = layout;
|
|
149
136
|
|
|
150
137
|
this.fillRawBufferTarget(frameBuffer, shared.buffer);
|
|
151
138
|
const base64Name = Buffer.from(shared.name).toString("base64");
|
|
@@ -178,21 +165,8 @@ export class NesImageRenderer {
|
|
|
178
165
|
footerRows: number,
|
|
179
166
|
pixelScale: number,
|
|
180
167
|
): string[] {
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
const cell = getCellDimensions();
|
|
184
|
-
const maxWidthByRows = Math.floor(
|
|
185
|
-
(maxRows * cell.heightPx * FRAME_WIDTH) / (FRAME_HEIGHT * cell.widthPx),
|
|
186
|
-
);
|
|
187
|
-
const maxWidthCells = Math.max(1, Math.min(widthCells, maxWidthByRows));
|
|
188
|
-
const maxWidthPx = Math.max(1, maxWidthCells * cell.widthPx);
|
|
189
|
-
const maxHeightPx = Math.max(1, maxRows * cell.heightPx);
|
|
190
|
-
const maxScale = Math.min(maxWidthPx / FRAME_WIDTH, maxHeightPx / FRAME_HEIGHT);
|
|
191
|
-
const requestedScale = Math.max(0.5, pixelScale) * maxScale;
|
|
192
|
-
const scale = Math.min(maxScale, requestedScale);
|
|
193
|
-
const columns = Math.max(1, Math.min(maxWidthCells, Math.floor((FRAME_WIDTH * scale) / cell.widthPx)));
|
|
194
|
-
const rows = Math.max(1, Math.min(maxRows, Math.floor((FRAME_HEIGHT * scale) / cell.heightPx)));
|
|
195
|
-
const padLeft = getHorizontalPadding(widthCells, columns);
|
|
168
|
+
const layout = computeKittyLayout(tui, widthCells, footerRows, pixelScale);
|
|
169
|
+
const { availableRows, columns, rows, padLeft } = layout;
|
|
196
170
|
|
|
197
171
|
this.fillRawBuffer(frameBuffer);
|
|
198
172
|
const fd = this.ensureRawFile();
|
|
@@ -235,23 +209,8 @@ export class NesImageRenderer {
|
|
|
235
209
|
footerRows: number,
|
|
236
210
|
pixelScale: number,
|
|
237
211
|
): string[] {
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
const cell = getCellDimensions();
|
|
241
|
-
const maxWidthByRows = Math.floor(
|
|
242
|
-
(maxRows * cell.heightPx * FRAME_WIDTH) / (FRAME_HEIGHT * cell.widthPx),
|
|
243
|
-
);
|
|
244
|
-
const maxWidthCells = Math.max(1, Math.min(widthCells, maxWidthByRows));
|
|
245
|
-
const maxWidthPx = Math.max(1, maxWidthCells * cell.widthPx);
|
|
246
|
-
const maxHeightPx = Math.max(1, maxRows * cell.heightPx);
|
|
247
|
-
const scale = Math.min(
|
|
248
|
-
maxWidthPx / FRAME_WIDTH,
|
|
249
|
-
maxHeightPx / FRAME_HEIGHT,
|
|
250
|
-
) * pixelScale;
|
|
251
|
-
const targetWidth = Math.max(1, Math.floor(FRAME_WIDTH * scale));
|
|
252
|
-
const targetHeight = Math.max(1, Math.floor(FRAME_HEIGHT * scale));
|
|
253
|
-
const columns = Math.max(1, Math.min(maxWidthCells, Math.floor(targetWidth / cell.widthPx)));
|
|
254
|
-
const padLeft = getHorizontalPadding(widthCells, columns);
|
|
212
|
+
const layout = computePngLayout(tui, widthCells, footerRows, pixelScale);
|
|
213
|
+
const { availableRows, maxWidthCells, padLeft, targetHeight, targetWidth } = layout;
|
|
255
214
|
|
|
256
215
|
const hash = hashFrame(frameBuffer, targetWidth, targetHeight);
|
|
257
216
|
if (!this.cachedImage || this.lastFrameHash !== hash) {
|
|
@@ -293,28 +252,13 @@ export class NesImageRenderer {
|
|
|
293
252
|
}
|
|
294
253
|
|
|
295
254
|
private fillRawBufferTarget(frameBuffer: FrameBuffer, target: Uint8Array): void {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
target.set(source.subarray(0, RAW_FRAME_BYTES));
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
const max = Math.min(source.length, RAW_FRAME_BYTES);
|
|
303
|
-
for (let i = 0; i < max; i += 1) {
|
|
304
|
-
target[i] = source[i] ?? 0;
|
|
305
|
-
}
|
|
255
|
+
const source = frameBuffer.data;
|
|
256
|
+
if (source.length >= RAW_FRAME_BYTES) {
|
|
257
|
+
target.set(source.subarray(0, RAW_FRAME_BYTES));
|
|
306
258
|
return;
|
|
307
259
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const source = frameBuffer.data as ReadonlyArray<number>;
|
|
311
|
-
for (let i = 0; i < FRAME_WIDTH * FRAME_HEIGHT; i += 1) {
|
|
312
|
-
const color = source[i] ?? 0;
|
|
313
|
-
target[offset] = color & 0xff;
|
|
314
|
-
target[offset + 1] = (color >> 8) & 0xff;
|
|
315
|
-
target[offset + 2] = (color >> 16) & 0xff;
|
|
316
|
-
offset += 3;
|
|
317
|
-
}
|
|
260
|
+
target.set(source);
|
|
261
|
+
target.fill(0, source.length, RAW_FRAME_BYTES);
|
|
318
262
|
}
|
|
319
263
|
|
|
320
264
|
private getSharedMemoryModule(): KittyShmModule | null {
|
|
@@ -459,6 +403,40 @@ function getMaxImageRows(tui: TUI, footerRows: number): number {
|
|
|
459
403
|
return Math.max(1, Math.floor(availableRows * IMAGE_HEIGHT_RATIO));
|
|
460
404
|
}
|
|
461
405
|
|
|
406
|
+
function computeLayoutBase(tui: TUI, widthCells: number, footerRows: number) {
|
|
407
|
+
const availableRows = getAvailableRows(tui, footerRows);
|
|
408
|
+
const maxRows = getMaxImageRows(tui, footerRows);
|
|
409
|
+
const cell = getCellDimensions();
|
|
410
|
+
const maxWidthByRows = Math.floor(
|
|
411
|
+
(maxRows * cell.heightPx * FRAME_WIDTH) / (FRAME_HEIGHT * cell.widthPx),
|
|
412
|
+
);
|
|
413
|
+
const maxWidthCells = Math.max(1, Math.min(widthCells, maxWidthByRows));
|
|
414
|
+
const maxWidthPx = Math.max(1, maxWidthCells * cell.widthPx);
|
|
415
|
+
const maxHeightPx = Math.max(1, maxRows * cell.heightPx);
|
|
416
|
+
return { availableRows, maxRows, cell, maxWidthCells, maxWidthPx, maxHeightPx };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function computeKittyLayout(tui: TUI, widthCells: number, footerRows: number, pixelScale: number) {
|
|
420
|
+
const base = computeLayoutBase(tui, widthCells, footerRows);
|
|
421
|
+
const maxScale = Math.min(base.maxWidthPx / FRAME_WIDTH, base.maxHeightPx / FRAME_HEIGHT);
|
|
422
|
+
const requestedScale = Math.max(0.5, pixelScale) * maxScale;
|
|
423
|
+
const scale = Math.min(maxScale, requestedScale);
|
|
424
|
+
const columns = Math.max(1, Math.min(base.maxWidthCells, Math.floor((FRAME_WIDTH * scale) / base.cell.widthPx)));
|
|
425
|
+
const rows = Math.max(1, Math.min(base.maxRows, Math.floor((FRAME_HEIGHT * scale) / base.cell.heightPx)));
|
|
426
|
+
const padLeft = getHorizontalPadding(widthCells, columns);
|
|
427
|
+
return { ...base, columns, rows, padLeft };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function computePngLayout(tui: TUI, widthCells: number, footerRows: number, pixelScale: number) {
|
|
431
|
+
const base = computeLayoutBase(tui, widthCells, footerRows);
|
|
432
|
+
const scale = Math.min(base.maxWidthPx / FRAME_WIDTH, base.maxHeightPx / FRAME_HEIGHT) * pixelScale;
|
|
433
|
+
const targetWidth = Math.max(1, Math.floor(FRAME_WIDTH * scale));
|
|
434
|
+
const targetHeight = Math.max(1, Math.floor(FRAME_HEIGHT * scale));
|
|
435
|
+
const columns = Math.max(1, Math.min(base.maxWidthCells, Math.floor(targetWidth / base.cell.widthPx)));
|
|
436
|
+
const padLeft = getHorizontalPadding(widthCells, columns);
|
|
437
|
+
return { ...base, targetWidth, targetHeight, padLeft };
|
|
438
|
+
}
|
|
439
|
+
|
|
462
440
|
function centerLines(lines: string[], totalRows: number): string[] {
|
|
463
441
|
if (lines.length >= totalRows) {
|
|
464
442
|
return lines;
|
|
@@ -495,37 +473,24 @@ function insertPaddingAfterMoveUp(line: string, padLeft: number): string {
|
|
|
495
473
|
}
|
|
496
474
|
|
|
497
475
|
function readRgb(frameBuffer: FrameBuffer, index: number): [number, number, number] {
|
|
498
|
-
|
|
499
|
-
const data = frameBuffer.data as ReadonlyArray<number>;
|
|
500
|
-
const base = index * 3;
|
|
501
|
-
return [data[base] ?? 0, data[base + 1] ?? 0, data[base + 2] ?? 0];
|
|
502
|
-
}
|
|
503
|
-
const data = frameBuffer.data as ReadonlyArray<number>;
|
|
504
|
-
const color = data[index] ?? 0;
|
|
505
|
-
return [color & 0xff, (color >> 8) & 0xff, (color >> 16) & 0xff];
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
function readPacked(frameBuffer: FrameBuffer, index: number): number {
|
|
509
|
-
if (frameBuffer.format === "packed") {
|
|
510
|
-
const data = frameBuffer.data as ReadonlyArray<number>;
|
|
511
|
-
return data[index] ?? 0;
|
|
512
|
-
}
|
|
513
|
-
const data = frameBuffer.data as ReadonlyArray<number>;
|
|
476
|
+
const data = frameBuffer.data;
|
|
514
477
|
const base = index * 3;
|
|
515
|
-
|
|
516
|
-
const g = data[base + 1] ?? 0;
|
|
517
|
-
const b = data[base + 2] ?? 0;
|
|
518
|
-
return r | (g << 8) | (b << 16);
|
|
478
|
+
return [data[base] ?? 0, data[base + 1] ?? 0, data[base + 2] ?? 0];
|
|
519
479
|
}
|
|
520
480
|
|
|
521
481
|
function hashFrame(frameBuffer: FrameBuffer, width: number, height: number): number {
|
|
522
482
|
let hash = width ^ (height << 16);
|
|
523
483
|
const stepX = Math.max(1, Math.floor(FRAME_WIDTH / 64));
|
|
524
484
|
const stepY = Math.max(1, Math.floor(FRAME_HEIGHT / 64));
|
|
485
|
+
const data = frameBuffer.data;
|
|
525
486
|
for (let y = 0; y < FRAME_HEIGHT; y += stepY) {
|
|
526
487
|
const rowOffset = y * FRAME_WIDTH;
|
|
527
488
|
for (let x = 0; x < FRAME_WIDTH; x += stepX) {
|
|
528
|
-
const
|
|
489
|
+
const base = (rowOffset + x) * 3;
|
|
490
|
+
const r = data[base] ?? 0;
|
|
491
|
+
const g = data[base + 1] ?? 0;
|
|
492
|
+
const b = data[base + 2] ?? 0;
|
|
493
|
+
const color = r | (g << 8) | (b << 16);
|
|
529
494
|
hash = ((hash << 5) - hash + color) | 0;
|
|
530
495
|
}
|
|
531
496
|
}
|