diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-edit.tsx b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-edit.tsx
index 8c27540..4c0d889 100644
--- a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-edit.tsx
+++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-edit.tsx
@@ -25,6 +25,7 @@ import {
getItemFile,
getItemCombinationDataFile,
getQuestFile,
+ getZoneDataFile,
getMaps,
type ItemFileNameEncoding,
} from '@/lib/api';
@@ -36,6 +37,7 @@ import { DropFileEdit } from './drop-file-edit';
import { ItemFileEdit } from './item-file-edit';
import { ItemCombinationDataFileEdit } from './item-combination-data-file-edit';
import { QuestFileEdit } from './quest-file-edit';
+import { ZoneDataFileEdit } from './zone-data-file-edit';
import { queryKeys } from '@/constants';
interface FileEditProps {
@@ -121,6 +123,16 @@ export function FileEdit({ filePath }: FileEditProps) {
enabled: !!filePath && fileType === 'a3_quest_file' && canEditFiles,
});
+ const {
+ data: zoneDataFileData,
+ isLoading: zoneDataFileLoading,
+ error: zoneDataFileError,
+ } = useQuery({
+ queryKey: queryKeys.zoneDataFile(filePath),
+ queryFn: () => getZoneDataFile({ path: filePath }),
+ enabled: !!filePath && fileType === 'a3_zone_data_file' && canEditFiles,
+ });
+
const {
data: dropFileData,
isLoading: dropFileLoading,
@@ -230,6 +242,7 @@ export function FileEdit({ filePath }: FileEditProps) {
itemFileError,
itemCombinationDataFileError,
questFileError,
+ zoneDataFileError,
]);
return (
@@ -278,7 +291,8 @@ export function FileEdit({ filePath }: FileEditProps) {
dropFileError ||
itemFileError ||
itemCombinationDataFileError ||
- questFileError) && (
+ questFileError ||
+ zoneDataFileError) && (
{fileContentErrorMessage}
@@ -458,6 +472,22 @@ export function FileEdit({ filePath }: FileEditProps) {
)}
>
)}
+ {fileType === 'a3_zone_data_file' && (
+ <>
+ {zoneDataFileLoading && (
+
+
+
+ )}
+ {zoneDataFileData && !zoneDataFileError && (
+
+ )}
+ >
+ )}
{fileType &&
fileType !== 'text_file' &&
fileType !== 'a3_npc_file' &&
@@ -465,7 +495,8 @@ export function FileEdit({ filePath }: FileEditProps) {
fileType !== 'a3_drop_file' &&
!isItemFileType(fileType) &&
fileType !== 'a3_item_combination_data_file' &&
- fileType !== 'a3_quest_file' && (
+ fileType !== 'a3_quest_file' &&
+ fileType !== 'a3_zone_data_file' && (
File type "{fileType}" is not yet supported for editing.
diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-view.tsx b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-view.tsx
index 2faed84..8a79dd1 100644
--- a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-view.tsx
+++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/file-view.tsx
@@ -39,6 +39,7 @@ import {
getItemFile,
getItemCombinationDataFile,
getQuestFile,
+ getZoneDataFile,
getRevisionSummary,
createFileDownloadLink,
revertFile,
@@ -53,6 +54,7 @@ import { DropFileView } from '@/components/drop-file-view';
import { ItemFileView } from '@/components/item-file-view';
import { ItemCombinationDataFileView } from '@/components/item-combination-data-file-view';
import { QuestFileView } from '@/components/quest-file-view';
+import { ZoneDataFileView } from '@/components/zone-data-file-view';
import { toast } from 'sonner';
import { queryKeys } from '@/constants';
@@ -141,6 +143,16 @@ export function FileView({ filePath }: FileViewProps) {
enabled: !!filePath && fileType === 'a3_quest_file',
});
+ const {
+ data: zoneDataFileData,
+ isLoading: zoneDataFileLoading,
+ error: zoneDataFileError,
+ } = useQuery({
+ queryKey: queryKeys.zoneDataFile(filePath),
+ queryFn: () => getZoneDataFile({ path: filePath }),
+ enabled: !!filePath && fileType === 'a3_zone_data_file',
+ });
+
const {
data: dropFileData,
isLoading: dropFileLoading,
@@ -244,6 +256,9 @@ export function FileView({ filePath }: FileViewProps) {
queryClient.invalidateQueries({
queryKey: queryKeys.questFile(filePath),
});
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.zoneDataFile(filePath),
+ });
queryClient.invalidateQueries({
queryKey: queryKeys.fileTree(filePath),
});
@@ -319,6 +334,7 @@ export function FileView({ filePath }: FileViewProps) {
itemFileError,
itemCombinationDataFileError,
questFileError,
+ zoneDataFileError,
]);
const getDirectoryPath = (filePath: string): string => {
@@ -427,7 +443,8 @@ export function FileView({ filePath }: FileViewProps) {
dropFileError ||
itemFileError ||
itemCombinationDataFileError ||
- questFileError) && (
+ questFileError ||
+ zoneDataFileError) && (
{fileContentErrorMessage}
@@ -668,6 +685,19 @@ export function FileView({ filePath }: FileViewProps) {
>
)}
+ {fileType === 'a3_zone_data_file' && (
+ <>
+ {zoneDataFileLoading && (
+
+
+
+ )}
+ {zoneDataFileData && !zoneDataFileError && (
+
+ )}
+ >
+ )}
+
{fileType &&
fileType !== 'text_file' &&
fileType !== 'a3_npc_file' &&
@@ -676,13 +706,15 @@ export function FileView({ filePath }: FileViewProps) {
!isItemFileType(fileType) &&
fileType !== 'a3_item_combination_data_file' &&
fileType !== 'a3_quest_file' &&
+ fileType !== 'a3_zone_data_file' &&
!textFileLoading &&
!npcFileLoading &&
!spawnFileLoading &&
!dropFileLoading &&
!itemFileLoading &&
!itemCombinationDataFileLoading &&
- !questFileLoading && (
+ !questFileLoading &&
+ !zoneDataFileLoading && (
File type "{fileType}" is not yet supported for
diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/zone-data-file-edit.tsx b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/zone-data-file-edit.tsx
new file mode 100644
index 0000000..a029692
--- /dev/null
+++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/zone-data-file-edit.tsx
@@ -0,0 +1,521 @@
+import { useMemo, useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { Loader2, Save } from 'lucide-react';
+import { toast } from 'sonner';
+import { APIError, updateZoneDataFile } from '@/lib/api';
+import type { ZoneDataField, ZoneDataFile, ZoneDataOperation } from '@/lib/api';
+import { queryKeys } from '@/constants';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { ZoneMapCanvas, fieldsForScope } from './zone-data-file-view';
+
+const pageSize = 25;
+
+export function ZoneDataFileEdit({
+ filePath,
+ defaultData,
+}: {
+ filePath: string;
+ defaultData: ZoneDataFile;
+}) {
+ const [operations, setOperations] = useState([]);
+ const queryClient = useQueryClient();
+ const mutation = useMutation({
+ mutationFn: () =>
+ updateZoneDataFile(
+ { path: filePath },
+ { source_hash: defaultData.source_hash, operations },
+ ),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.zoneDataFile(filePath),
+ });
+ queryClient.invalidateQueries({ queryKey: queryKeys.fileTree(filePath) });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.revisionSummary(filePath),
+ });
+ toast.success('ZoneData file updated');
+ setOperations([]);
+ },
+ onError: (error) => {
+ toast.error(
+ error instanceof APIError
+ ? error.getErrorMessage()
+ : error instanceof Error
+ ? error.message
+ : 'Failed to update ZoneData file',
+ );
+ },
+ });
+
+ const setValue = (
+ scope: ZoneDataField['scope'],
+ row: number,
+ field: string,
+ value: number | string | boolean,
+ ) => {
+ setOperations((current) => {
+ const next = current.filter(
+ (operation) =>
+ !(
+ operation.scope === scope &&
+ operation.row === row &&
+ operation.field === field
+ ),
+ );
+ next.push({ scope, row, field, value });
+ return next;
+ });
+ };
+
+ const valueFor = (
+ scope: ZoneDataField['scope'],
+ row: number,
+ field: string,
+ original: number | string | boolean,
+ ) =>
+ operations.find(
+ (operation) =>
+ operation.scope === scope &&
+ operation.row === row &&
+ operation.field === field,
+ )?.value ?? original;
+
+ return (
+
+ {defaultData.map ? (
+
+ ) : (
+
+ )}
+
+
+
{operations.length} pending changes
+
+ Opaque bytes stay read-only and are checked before saving.
+
+
+
+
+
+ );
+}
+
+function ZoneDataTableEdit({
+ data,
+ setValue,
+ valueFor,
+}: {
+ data: ZoneDataFile;
+ setValue: SetValue;
+ valueFor: ValueFor;
+}) {
+ const [page, setPage] = useState(0);
+ const fields = fieldsForScope(data, 'row');
+ const allRows = data.rows ?? [];
+ const pageCount = Math.max(1, Math.ceil(allRows.length / pageSize));
+ const rows = allRows.slice(page * pageSize, (page + 1) * pageSize);
+
+ return (
+
+
+ Decoded fields
+
+
+
+
+
+
+ Row
+ {fields.map((field) => (
+
+ {field.label}
+
+ ))}
+ Opaque bytes
+
+
+
+ {rows.map((row) => (
+
+
+ {row.index}
+
+ {fields.map((field) => (
+
+
+ setValue('row', row.index, field.key, value)
+ }
+ />
+
+ ))}
+
+
+ {row.opaque_bytes}
+
+
+
+ ))}
+
+
+
+
+
+ Page {page + 1} of {pageCount}
+
+
+
+
+
+
+
+
+ );
+}
+
+function ZoneMapEdit({
+ data,
+ operations,
+ setValue,
+ valueFor,
+}: ZoneMapEditProps) {
+ const map = data.map!;
+ const [selectedCell, setSelectedCell] = useState(0);
+ const [zoom, setZoom] = useState(2);
+ const patchedCells = useMemo(() => {
+ const cells = [...map.cells];
+ for (const operation of operations) {
+ if (operation.scope !== 'cell') {
+ continue;
+ }
+
+ const raw = cells[operation.row] ?? 0;
+ if (operation.field === 'can_move') {
+ cells[operation.row] = operation.value ? raw | 1 : raw & ~1;
+ }
+ if (operation.field === 'pk_level') {
+ cells[operation.row] =
+ (raw & ~(3 << 15)) | (Number(operation.value) << 15);
+ }
+ if (operation.field === 'warp_index') {
+ cells[operation.row] =
+ (raw & ~(15 << 11)) | (Number(operation.value) << 11);
+ }
+ }
+ return cells;
+ }, [map.cells, operations]);
+ const raw = patchedCells[selectedCell] ?? 0;
+ const x = selectedCell % map.width;
+ const y = Math.floor(selectedCell / map.width);
+
+ return (
+
+
+
+
+
+
+ setValue('map', 0, 'name', event.target.value)
+ }
+ className="mt-2 w-28"
+ />
+
+
+
+ {zoom}×
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cell {x}, {y}
+
+
+
+
+
+ setValue('cell', selectedCell, 'can_move', checked === true)
+ }
+ />
+
+
+ >> 15) & 3}
+ min={0}
+ max={3}
+ onChange={(value) =>
+ setValue('cell', selectedCell, 'pk_level', value)
+ }
+ />
+ >> 11) & 15}
+ min={0}
+ max={15}
+ onChange={(value) =>
+ setValue('cell', selectedCell, 'warp_index', value)
+ }
+ />
+
+
+
+ 0x{raw.toString(16).padStart(8, '0')}
+
+
+
+
+
+
+ Warps
+
+
+ {map.warps.map((warp) => (
+
+ {fieldsForScope(data, 'warp').map((field) => {
+ const value = parseNumberInput(
+ String(
+ valueFor(
+ 'warp',
+ warp.index,
+ field.key,
+ warp.values[field.key] ?? 0,
+ ),
+ ),
+ 0,
+ );
+
+ return (
+
+
+
+ setValue(
+ 'warp',
+ warp.index,
+ field.key,
+ parseNumberInput(event.target.value, value),
+ )
+ }
+ />
+
+ );
+ })}
+
+ ))}
+
+
+
+
+ );
+}
+
+function FieldInput({
+ field,
+ value,
+ onChange,
+}: {
+ field: ZoneDataField;
+ value: number | string | boolean;
+ onChange: (value: number | string | boolean) => void;
+}) {
+ if (field.type === 'boolean') {
+ return (
+ onChange(checked === true)}
+ aria-label={field.label}
+ />
+ );
+ }
+
+ return (
+
+ onChange(
+ field.type === 'integer'
+ ? parseNumberInput(event.target.value, Number(value))
+ : event.target.value,
+ )
+ }
+ />
+ );
+}
+
+function LabeledNumber({
+ id,
+ label,
+ value,
+ min,
+ max,
+ onChange,
+}: LabeledNumberProps) {
+ return (
+
+
+
+ onChange(parseNumberInput(event.target.value, value))
+ }
+ className="mt-2"
+ />
+
+ );
+}
+
+function parseNumberInput(value: string, fallback: number) {
+ const safeFallback = Number.isFinite(fallback) ? fallback : 0;
+
+ if (value.trim() === '') {
+ return safeFallback;
+ }
+
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : safeFallback;
+}
+
+type SetValue = (
+ scope: ZoneDataField['scope'],
+ row: number,
+ field: string,
+ value: number | string | boolean,
+) => void;
+
+type ValueFor = (
+ scope: ZoneDataField['scope'],
+ row: number,
+ field: string,
+ original: number | string | boolean,
+) => number | string | boolean;
+
+interface ZoneMapEditProps {
+ data: ZoneDataFile;
+ operations: ZoneDataOperation[];
+ setValue: SetValue;
+ valueFor: ValueFor;
+}
+
+interface LabeledNumberProps {
+ id: string;
+ label: string;
+ value: number;
+ min: number;
+ max: number;
+ onChange: (value: number) => void;
+}
diff --git a/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/zone-data-file-view.tsx b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/zone-data-file-view.tsx
new file mode 100644
index 0000000..9bb015c
--- /dev/null
+++ b/cmd/omnihance-a3-agent/omnihance-a3-agent-ui/src/components/zone-data-file-view.tsx
@@ -0,0 +1,356 @@
+import {
+ memo,
+ useDeferredValue,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import type { ZoneDataFile, ZoneDataField } from '@/lib/api';
+
+const pageSize = 50;
+
+export function ZoneDataFileView({ data }: { data: ZoneDataFile }) {
+ if (data.map) {
+ return ;
+ }
+
+ return ;
+}
+
+function ZoneDataTable({ data }: { data: ZoneDataFile }) {
+ const [query, setQuery] = useState('');
+ const [page, setPage] = useState(0);
+ const deferredQuery = useDeferredValue(query.toLowerCase());
+ const fields = data.schema.filter((field) => field.scope === 'row');
+ const filteredRows = useMemo(() => {
+ if (!deferredQuery) {
+ return data.rows ?? [];
+ }
+
+ return (data.rows ?? []).filter((row) =>
+ JSON.stringify(row.values).toLowerCase().includes(deferredQuery),
+ );
+ }, [data.rows, deferredQuery]);
+ const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize));
+ const currentPage = Math.min(page, pageCount - 1);
+ const rows = filteredRows.slice(
+ currentPage * pageSize,
+ (currentPage + 1) * pageSize,
+ );
+
+ return (
+
+
+
+
{formatName(data.format)}
+
+ {filteredRows.length.toLocaleString()} decoded rows
+
+
+ {
+ setQuery(event.target.value);
+ setPage(0);
+ }}
+ aria-label="Search decoded rows"
+ placeholder="Search rows"
+ className="sm:max-w-xs"
+ />
+
+
+
+
+
+
+ Row
+ {fields.map((field) => (
+
+ {field.label}
+
+ ))}
+ Opaque bytes
+
+
+
+ {rows.map((row) => (
+
+
+ {row.index}
+
+ {fields.map((field) => (
+
+ {String(row.values[field.key] ?? '')}
+
+ ))}
+
+
+ {row.opaque_bytes}
+
+
+
+ ))}
+
+
+
+
+
+ Page {currentPage + 1} of {pageCount}
+
+
+
+
+
+
+
+
+ );
+}
+
+function ZoneMapView({ data }: { data: ZoneDataFile }) {
+ const map = data.map!;
+ const [selectedCell, setSelectedCell] = useState(0);
+ const [zoom, setZoom] = useState(2);
+ const raw = map.cells[selectedCell] ?? 0;
+ const x = selectedCell % map.width;
+ const y = Math.floor(selectedCell / map.width);
+ const warpIndex = (raw >>> 11) & 0x0f;
+
+ return (
+
+
+
+
+
{map.name || 'Zone map'}
+
+ {map.width}×{map.height} cells · {map.warps.length} warps
+
+
+
+
+ {zoom}×
+
+
+
+
+
+
+
+
+
+
+
+
+ Selected cell
+
+
+
+
+
+
+ >> 15) & 0x03} />
+
+
+
+
+
+ Warp overlay
+
+
+ {map.warps.length === 0 && (
+ No warp rows.
+ )}
+ {map.warps.map((warp) => (
+
+ Warp {warp.index}
+
+ Map {String(warp.values.map_id)} · Cell{' '}
+ {String(warp.values.cell)}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+export const ZoneMapCanvas = memo(function ZoneMapCanvas({
+ cells,
+ width,
+ height,
+ selectedCell,
+ zoom,
+ onSelect,
+}: {
+ cells: number[];
+ width: number;
+ height: number;
+ selectedCell: number;
+ zoom: number;
+ onSelect: (index: number) => void;
+}) {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const context = canvas?.getContext('2d');
+ if (!canvas || !context) {
+ return;
+ }
+
+ const image = context.createImageData(width, height);
+ for (let index = 0; index < cells.length; index += 1) {
+ const raw = cells[index] ?? 0;
+ const warp = ((raw >>> 11) & 0x0f) !== 15;
+ const movable = (raw & 1) !== 0;
+ const offset = index * 4;
+ image.data[offset] = warp ? 245 : movable ? 83 : 30;
+ image.data[offset + 1] = warp ? 158 : movable ? 178 : 41;
+ image.data[offset + 2] = warp ? 11 : movable ? 109 : 59;
+ image.data[offset + 3] = 255;
+ }
+ context.putImageData(image, 0, 0);
+ context.strokeStyle = '#ffffff';
+ context.lineWidth = 1;
+ context.strokeRect(
+ selectedCell % width,
+ Math.floor(selectedCell / width),
+ 1,
+ 1,
+ );
+ }, [cells, height, selectedCell, width]);
+
+ const selectFromPointer = (clientX: number, clientY: number) => {
+ const canvas = canvasRef.current;
+ if (!canvas) {
+ return;
+ }
+
+ const rect = canvas.getBoundingClientRect();
+ const x = Math.min(
+ width - 1,
+ Math.max(0, Math.floor(((clientX - rect.left) / rect.width) * width)),
+ );
+ const y = Math.min(
+ height - 1,
+ Math.max(0, Math.floor(((clientY - rect.top) / rect.height) * height)),
+ );
+ onSelect(y * width + x);
+ };
+
+ return (
+