commit cbfad06e6fcfb66c8f6678dbb2e2b096df744726 Author: Sebastian Jeong Date: Sun Aug 16 06:12:54 2026 +0900 Initial DOS VM tooling setup diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4920e87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# QEMU runtime state, downloaded media, and generated captures +.qemu/* +!.qemu/*.ps1 +!.qemu/*.mjs +!.qemu/*.txt +!.qemu/share/ +!.qemu/share/*.c + +.qemu/*.qcow2 +.qemu/*.img +.qemu/*.iso +.qemu/*.zip +.qemu/*.png +.qemu/*.ppm +.qemu/*.tmp + +# Local logs and editor/OS metadata +*.log +*.tmp +*.swp +*~ +.DS_Store +Thumbs.db + +# Node/tool caches +node_modules/ +.npm/ +.cache/ + +# Temporary DOS build sources +.qemu/share/dosag*.c diff --git a/.pi/extensions/dos-vm.md b/.pi/extensions/dos-vm.md new file mode 100644 index 0000000..5893081 --- /dev/null +++ b/.pi/extensions/dos-vm.md @@ -0,0 +1,20 @@ +# DOS VM tools + +This project-local Pi extension exposes `dos_read`, `dos_write`, `dos_edit`, +`dos_list`, and `dos_exec`. It communicates with `DOSAGENT.EXE` over COM1 +through the serial relay controller port at `127.0.0.1:5555`. + +The agent is not started automatically. From the FreeDOS VGA console, run +`DOSAGENT` (installed in `C:\\FREEDOS\\BIN`, which is on `PATH`) when Pi tool +access is needed. While it runs, press Ctrl+C in the VGA console to stop it and +return to the DOS prompt; Pi tools then cannot connect until it is started +again. QEMU monitor control remains available at `127.0.0.1:4444`. + +Connect PuTTY in Raw mode to `127.0.0.1:5557` for a read-only decoded mirror of +Pi/DOS serial traffic. Port 5556 is reserved for the internal QEMU-to-relay +connection. + +`D:` is QEMU's FAT-directory view of `.qemu\\share`. Treat it as an exchange +volume, not a live-sync mount: host-side changes can be stale or partially +visible to a running VM. Restart QEMU before compiling host-edited files, or +use `dos_write` to place the source on `C:` before compiling. diff --git a/.pi/extensions/dos-vm.ts b/.pi/extensions/dos-vm.ts new file mode 100644 index 0000000..3721710 --- /dev/null +++ b/.pi/extensions/dos-vm.ts @@ -0,0 +1,222 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import net from "node:net"; + +const HOST = "127.0.0.1"; +const PORT = 5555; +const MONITOR_PORT = 4444; +const CHUNK_SIZE = 4096; +const MAX_FILE_SIZE = 4 * 1024 * 1024; + +function hexEncode(value: string | Buffer): string { + return Buffer.from(value).toString("hex").toUpperCase(); +} + +function hexDecode(value: string): Buffer { + return Buffer.from(value, "hex"); +} + +function normalizePath(path: string): string { + const result = path.startsWith("@") ? path.slice(1) : path; + if (!/^[A-Za-z]:[\\/]/.test(result)) { + throw new Error(`DOS path must be absolute (for example C:\\SRC\\FILE.C): ${result}`); + } + return result.replaceAll("/", "\\"); +} + +async function request(command: string, signal?: AbortSignal, timeoutMs = 30_000): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host: HOST, port: PORT }); + let buffered = ""; + let settled = false; + + const finish = (error?: Error, value?: string) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + socket.destroy(); + if (error) reject(error); else resolve(value ?? ""); + }; + const abort = () => finish(new Error("DOS tool call aborted")); + const timer = setTimeout(() => finish(new Error(`DOS agent timed out after ${timeoutMs}ms`)), timeoutMs); + + signal?.addEventListener("abort", abort, { once: true }); + socket.setEncoding("ascii"); + socket.on("connect", () => socket.write(`${command}\r\n`, "ascii")); + socket.on("data", (chunk) => { + buffered += chunk; + const lines = buffered.split(/\r?\n/); + buffered = lines.pop() ?? ""; + for (const line of lines) { + if (line.startsWith("OK ")) return finish(undefined, line.slice(3)); + if (line === "OK") return finish(undefined, ""); + if (line.startsWith("ERR ")) return finish(new Error(hexDecode(line.slice(4)).toString("utf8"))); + } + }); + socket.on("error", (error) => finish(new Error(`Cannot connect to DOS agent at ${HOST}:${PORT}: ${error.message}`))); + socket.on("close", () => { + if (!settled) finish(new Error("DOS serial connection closed before a response")); + }); + }); +} + +async function interruptForegroundCommand(): Promise { + await new Promise((resolve, reject) => { + const socket = net.createConnection({ host: HOST, port: MONITOR_PORT }); + const timer = setTimeout(() => { socket.destroy(); reject(new Error("QEMU monitor did not accept Ctrl+C")); }, 5_000); + socket.on("connect", () => socket.write("sendkey ctrl-c\r\n", "ascii")); + socket.on("data", () => { clearTimeout(timer); socket.destroy(); resolve(); }); + socket.on("error", (error) => { clearTimeout(timer); reject(new Error(`Cannot contact QEMU monitor: ${error.message}`)); }); + }); +} + +async function waitForAgentRecovery(timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + if (await request("PING", undefined, 1_000) === "504F4E47") return true; + } catch { /* Command is still unwinding. */ } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return false; +} + +async function readFile(path: string, signal?: AbortSignal): Promise { + const dosPath = normalizePath(path); + const chunks: Buffer[] = []; + let size = 0; + let offset = 0; + for (;;) { + const response = await request(`READ ${hexEncode(dosPath)} ${offset}`, signal); + const match = /^([01])(?:\s(.*))?$/.exec(response); + if (!match) throw new Error(`Malformed READ response: ${response}`); + const chunk = hexDecode(match[2] ?? ""); + chunks.push(chunk); + size += chunk.length; + if (size > MAX_FILE_SIZE) throw new Error(`DOS file exceeds ${MAX_FILE_SIZE} byte tool limit`); + offset += chunk.length; + if (match[1] === "1") return Buffer.concat(chunks, size); + if (chunk.length === 0) throw new Error("DOS agent returned an empty non-final chunk"); + } +} + +async function writeFile(path: string, content: Buffer, signal?: AbortSignal): Promise { + const dosPath = normalizePath(path); + if (content.length > MAX_FILE_SIZE) throw new Error(`Content exceeds ${MAX_FILE_SIZE} byte tool limit`); + if (content.length === 0) { + await request(`WRITE ${hexEncode(dosPath)} T `, signal); + return; + } + for (let offset = 0; offset < content.length; offset += CHUNK_SIZE) { + const chunk = content.subarray(offset, offset + CHUNK_SIZE); + await request(`WRITE ${hexEncode(dosPath)} ${offset === 0 ? "T" : "A"} ${hexEncode(chunk)}`, signal); + } +} + +function textResult(text: string) { + return { content: [{ type: "text" as const, text }], details: {} }; +} + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "dos_read", + label: "DOS Read", + description: "Read a text file from the running FreeDOS VM over COM1.", + promptSnippet: "Read files inside the running FreeDOS VM", + parameters: Type.Object({ path: Type.String({ description: "Absolute DOS path such as C:\\SRC\\MAIN.C" }) }), + async execute(_id, params, signal) { + const content = await readFile(params.path, signal); + return textResult(content.toString("utf8")); + }, + }); + + pi.registerTool({ + name: "dos_write", + label: "DOS Write", + description: "Create or replace a text file in the running FreeDOS VM over COM1.", + promptSnippet: "Write files inside the running FreeDOS VM", + parameters: Type.Object({ + path: Type.String({ description: "Absolute DOS path" }), + content: Type.String({ description: "Complete UTF-8 text content" }), + }), + async execute(_id, params, signal) { + const content = Buffer.from(params.content, "utf8"); + await writeFile(params.path, content, signal); + return textResult(`Wrote ${content.length} bytes to ${normalizePath(params.path)}`); + }, + }); + + pi.registerTool({ + name: "dos_edit", + label: "DOS Edit", + description: "Replace one uniquely matching text fragment in a file in the running FreeDOS VM.", + promptSnippet: "Make exact text replacements inside FreeDOS files", + parameters: Type.Object({ + path: Type.String({ description: "Absolute DOS path" }), + old_text: Type.String({ description: "Text that must occur exactly once" }), + new_text: Type.String({ description: "Replacement text" }), + }), + async execute(_id, params, signal) { + const original = (await readFile(params.path, signal)).toString("utf8"); + const occurrences = original.split(params.old_text).length - 1; + if (occurrences !== 1) throw new Error(`Expected old_text exactly once, found ${occurrences} occurrences`); + const updated = original.replace(params.old_text, params.new_text); + await writeFile(params.path, Buffer.from(updated, "utf8"), signal); + return textResult(`Edited ${normalizePath(params.path)}`); + }, + }); + + pi.registerTool({ + name: "dos_list", + label: "DOS List", + description: "List a directory in the running FreeDOS VM over COM1.", + promptSnippet: "List directories inside the running FreeDOS VM", + parameters: Type.Object({ path: Type.String({ description: "Absolute DOS directory path" }) }), + async execute(_id, params, signal) { + const response = await request(`LIST ${hexEncode(normalizePath(params.path))}`, signal); + return textResult(hexDecode(response).toString("utf8")); + }, + }); + + pi.registerTool({ + name: "dos_exec", + label: "DOS Exec", + description: "Execute a command in the running FreeDOS VM and return its output.", + promptSnippet: "Run commands inside the running FreeDOS VM", + parameters: Type.Object({ + command: Type.String({ description: "FreeDOS command line" }), + timeout_seconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 120, description: "Abort after this many seconds (default: 30)" })), + }), + async execute(_id, params, signal) { + if (params.command.length > 600) throw new Error("DOS command exceeds 600 characters"); + const timeoutMs = (params.timeout_seconds ?? 30) * 1_000; + let response: string; + try { + response = await request(`EXEC ${hexEncode(params.command)}`, signal, timeoutMs); + } catch (error) { + if (signal?.aborted) throw error; + await interruptForegroundCommand(); + const recovered = await waitForAgentRecovery(); + throw new Error(`DOS command timed out after ${timeoutMs / 1_000}s and was interrupted with Ctrl+C${recovered ? "; agent recovered" : "; agent did not recover"}`); + } + const match = /^(-?\d+)(?:\s(.*))?$/.exec(response); + if (!match) throw new Error(`Malformed EXEC response: ${response}`); + const output = hexDecode(match[2] ?? "").toString("utf8"); + return textResult(`${output}${output.endsWith("\n") || !output ? "" : "\n"}[exit ${match[1]}]`); + }, + }); + + pi.registerTool({ + name: "dos_abort", + label: "DOS Abort", + description: "Send Ctrl+C to interrupt the foreground DOS command without rebooting the VM.", + promptSnippet: "Interrupt a hung DOS command without rebooting the VM", + parameters: Type.Object({}), + async execute() { + await interruptForegroundCommand(); + const recovered = await waitForAgentRecovery(); + return textResult(recovered ? "Sent Ctrl+C; DOS agent recovered." : "Sent Ctrl+C; DOS agent has not responded yet."); + }, + }); +} diff --git a/.qemu/install.ps1 b/.qemu/install.ps1 new file mode 100644 index 0000000..2d3ade9 --- /dev/null +++ b/.qemu/install.ps1 @@ -0,0 +1,28 @@ +param() + +$ErrorActionPreference = 'Stop' +$qemu = Get-Command qemu-system-i386.exe -ErrorAction Stop +$disk = Join-Path $PSScriptRoot 'freedos.qcow2' +$iso = Join-Path $PSScriptRoot 'FD14LIVE.iso' + +if (-not (Test-Path -LiteralPath $disk)) { + throw "Missing $disk. Create it first with: qemu-img create -f qcow2 .qemu\\freedos.qcow2 2G" +} +if (-not (Test-Path -LiteralPath $iso)) { + throw "Missing $iso. Run .qemu\\setup.ps1 first." +} +if (Get-NetTCPConnection -State Listen -LocalPort 4444 -ErrorAction SilentlyContinue) { + throw 'QEMU monitor port 4444 is already in use. Stop the existing VM first.' +} + +& $qemu.Source ` + -machine pc,usb=on ` + -cpu pentium3 ` + -m 64 ` + -drive "file=$disk,format=qcow2,if=ide,index=0,media=disk" ` + -drive "file=$iso,media=cdrom,readonly=on" ` + -nic user,model=ne2k_isa ` + -monitor "tcp:127.0.0.1:4444,server=on,wait=off" ` + -serial null ` + -boot order=d ` + -display default diff --git a/.qemu/monitor.ps1 b/.qemu/monitor.ps1 new file mode 100644 index 0000000..6a0322f --- /dev/null +++ b/.qemu/monitor.ps1 @@ -0,0 +1,36 @@ +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$Command +) + +$ErrorActionPreference = 'Stop' +$client = [System.Net.Sockets.TcpClient]::new() + +try { + $client.Connect('127.0.0.1', 4444) + $stream = $client.GetStream() + $stream.ReadTimeout = 1000 + $writer = [System.IO.StreamWriter]::new($stream, [System.Text.Encoding]::ASCII, 1024, $true) + $writer.NewLine = "`n" + $writer.AutoFlush = $true + + Start-Sleep -Milliseconds 100 + while ($stream.DataAvailable) { + $buffer = New-Object byte[] 4096 + [void]$stream.Read($buffer, 0, $buffer.Length) + } + + $writer.WriteLine($Command) + Start-Sleep -Milliseconds 200 + + $result = New-Object System.Text.StringBuilder + while ($stream.DataAvailable) { + $buffer = New-Object byte[] 4096 + $count = $stream.Read($buffer, 0, $buffer.Length) + [void]$result.Append([System.Text.Encoding]::ASCII.GetString($buffer, 0, $count)) + } + $result.ToString().Trim() +} +finally { + $client.Dispose() +} diff --git a/.qemu/readme.txt b/.qemu/readme.txt new file mode 100644 index 0000000..bd4facd --- /dev/null +++ b/.qemu/readme.txt @@ -0,0 +1,144 @@ +############################################################################### + FreeDOS 1.4 ("FreeDOS 1.4") +############################################################################### + + +------------------------------------------------------------------------------- + General system requirements: +------------------------------------------------------------------------------- + + * DOS-compatible system (Intel + BIOS, or UEFI with Legacy support) + + * At least 20MB free disk space: + + 20MB Plain DOS system + 30MB Plain DOS system, with sources + + 275MB Full installation including applications and games + 450MB Full installation with sources + + +------------------------------------------------------------------------------- + What's in all those zip files? +------------------------------------------------------------------------------- + +FD14-LiveCD.zip + + * FD14BOOT.IMG - Basic FreeDOS installation boot floppy image. + If your computer has a CD-ROM drive, but you cannot boot from the Live CD + or Legacy CD. Use this diskette image to boot the system. Then insert the + install CD. The FreeDOS installer should do the rest. This diskette + image is for installation purposes only and does not provide a Live + Environment. + + * FD14LIVE.ISO - The FreeDOS 1.4 installer. Most users should + use this image to install FreeDOS. + + Depending on your computer system and hardware configuration, you + can also use the LiveCD to boot and run FreeDOS directly from the + CD-ROM without installation to your hard drive. + +FD14-LegacyCD.zip + + * FD14BOOT.IMG - This zip archive also contains a copy of the basic + CD-ROM installation boot floppy. + + * FD14LGCY.ISO - A bootable CD image designed for older hardware. If + you cannot boot the LiveCD to install FreeDOS, try this disc image. + + This disc image uses the older El Torito boot CD format. Some newer + computers and virtual machines cannot use this older format. Unless + you have a computer that requires this type of bootable CD, we + recommend using the LiveCD instead. + +FD14-BonusCD.zip + + * FD14BNS.ISO - A non-bootable CD image that contains some FreeDOS + packages that are not installed as part of either the LiveCD or + the Legacy CD. + +FD14-LiteUSB.zip + + * FD14LITE.IMG - A minimal FreeDOS installer, as a USB fob drive + image. This does not contain all of the packages from either the + LiveCD or the LegacyCD, and instead only contains a basic set of + FreeDOS packages. + + * FD14LITE.VMDK - A virtual machine disk file, compatible with a + variety of virtual machine software including VirtualBox, VMware, + and other systems. + + Using a VMDK file can simplify installing FreeDOS. Just attach the + VMDK image to your virtual machine software as a hard drive, and + boot it. (Please note that you will still need to create a virtual + hard disk to install FreeDOS) + +FD14-FullUSB.zip + + * FD14FULL.IMG - Plain DOS system and Full install USB stick image. + + * FD14FULL.VMDK - A virtual machine disk file, compatible with a + variety of virtual machine software. Just attach the VMDK image to + your virtual machine as a hard drive, and boot it. + +VERIFY.TXT + + * Contains MD5, SHA256 and SHA512 hashes for all of the different + release files. You can verify your copy of FreeDOS with these. + +README.TXT + + * The "before you choose and install" document. (All of the zip + files listed above also have a copy of the README file.) + + +------------------------------------------------------------------------------- + FreeDOS Floppy-Only Edition (FD14-x86) +------------------------------------------------------------------------------- + +FreeDOS 1.4 includes a Floppy-Only Edition! This edition should run on +any hardware that can run FreeDOS and has EGA or better graphics: + + * Are you running a '286 or another classic system without a CD-ROM + drive? Install from these floppies to install FreeDOS. + + * Do you have just one hard drive and no CD or floppy drive? Just + copy the contents of the floppies to a temporary directory and run + the installer from there. + + * Want to perform a "headless" install to a different DOS directory? + It's easy with the command line options. + +The Floppy-Only Edition uses a completely different installer than +the CD-ROM or USB installers. The Floppy-Only Edition does not use +any of those other media to install. + +The Floppy-Only Edition contains a limited set of FreeDOS programs +that are more useful on classic PC hardware. + +The FreeDOS Floppy-Only Edition is distributed as single zip archive that +contains several pre-made floppy diskette images: + + These zip archives contain image files for several common floppy + diskette media under separate directories: + + * 720k - 3.5" 720k diskette images + + * 144m - 3.5" 1.44mb diskette images + + * 120m - 5.25" 1.2mb diskette images + + Each of those sets contain a number of pre-made disk images: + + * x86BOOT.img - A floppy boot disk image with the x86 installer. + + * x86DSK??.img - Several floppy diskette images that contain the + core FreeDOS operating system files. The number of floppy images + and amount of files on each varies depending on the diskette + capacity. + +To conserve space, the FreeDOS Floppy-Only Edition does not contain +the source code for the FreeDOS packages. You can find the source code +via the FreeDOS website (https://www.freedos.org/download/) or from +the other release media, like the USB or CD-ROM installer. + diff --git a/.qemu/run.ps1 b/.qemu/run.ps1 new file mode 100644 index 0000000..865e496 --- /dev/null +++ b/.qemu/run.ps1 @@ -0,0 +1,44 @@ +param() + +$ErrorActionPreference = 'Stop' +$qemu = Get-Command qemu-system-i386.exe -ErrorAction Stop +$node = Get-Command node.exe -ErrorAction Stop +$disk = Join-Path $PSScriptRoot 'freedos.qcow2' +$share = Join-Path $PSScriptRoot 'share' +$relay = Join-Path $PSScriptRoot 'serial-relay.mjs' + +# QEMU's FAT-directory drive is a convenient exchange volume, but it is not a +# reliable live-sync mechanism. Restart QEMU after host-side source changes. +New-Item -ItemType Directory -Force -Path $share | Out-Null + +if (-not (Test-Path -LiteralPath $disk)) { + throw "Missing $disk. Run .qemu\\setup.ps1, then .qemu\\install.ps1." +} + +$relayListening = Get-NetTCPConnection -State Listen -LocalPort 5556 -ErrorAction SilentlyContinue +if (-not $relayListening) { + Start-Process -FilePath $node.Source -ArgumentList @($relay) -WorkingDirectory $PSScriptRoot -WindowStyle Hidden + $deadline = [DateTime]::UtcNow.AddSeconds(5) + do { + Start-Sleep -Milliseconds 100 + $relayListening = Get-NetTCPConnection -State Listen -LocalPort 5556 -ErrorAction SilentlyContinue + } while (-not $relayListening -and [DateTime]::UtcNow -lt $deadline) + if (-not $relayListening) { throw 'Serial relay did not start.' } +} + +$monitorListening = Get-NetTCPConnection -State Listen -LocalPort 4444 -ErrorAction SilentlyContinue +if ($monitorListening) { + throw 'QEMU monitor port 4444 is already in use. Stop the existing VM before starting another one.' +} + +& $qemu.Source ` + -machine pc,accel=whpx,kernel-irqchip=off,usb=on ` + -smp 1 ` + -m 64 ` + -drive "file=$disk,format=qcow2,if=ide,index=0,media=disk" ` + -hdb "fat:rw:$share" ` + -nic user,model=ne2k_isa ` + -monitor "tcp:127.0.0.1:4444,server=on,wait=off" ` + -serial "tcp:127.0.0.1:5556" ` + -boot order=c ` + -display default diff --git a/.qemu/screenshot.ps1 b/.qemu/screenshot.ps1 new file mode 100644 index 0000000..bd5c93d --- /dev/null +++ b/.qemu/screenshot.ps1 @@ -0,0 +1,26 @@ +param( + [string]$Name = 'qemu-screen', + [switch]$KeepPpm +) + +$ErrorActionPreference = 'Stop' +$ffmpeg = Get-Command ffmpeg.exe -ErrorAction Stop + +$target = if ([IO.Path]::IsPathRooted($Name)) { $Name } else { Join-Path $PSScriptRoot $Name } +$extension = [IO.Path]::GetExtension($target) +if ($extension -in @('.png', '.ppm')) { + $target = [IO.Path]::Combine([IO.Path]::GetDirectoryName($target), [IO.Path]::GetFileNameWithoutExtension($target)) +} +$directory = [IO.Path]::GetDirectoryName($target) +if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null } + +$ppm = "$target.ppm" +$png = "$target.png" +$qemuPath = $ppm.Replace('\', '/') + +& (Join-Path $PSScriptRoot 'monitor.ps1') "screendump $qemuPath" | Out-Null +if (-not (Test-Path -LiteralPath $ppm)) { throw 'QEMU did not create the requested screenshot.' } +& $ffmpeg.Source -y -loglevel error -i $ppm $png +if ($LASTEXITCODE -ne 0) { throw "ffmpeg conversion failed with exit code $LASTEXITCODE." } +if (-not $KeepPpm) { Remove-Item -LiteralPath $ppm -Force } +Get-Item -LiteralPath $png diff --git a/.qemu/send-keys.ps1 b/.qemu/send-keys.ps1 new file mode 100644 index 0000000..3431978 --- /dev/null +++ b/.qemu/send-keys.ps1 @@ -0,0 +1,60 @@ +param( + [Parameter(Mandatory)] + [string] $Text, + + [ValidateRange(0, 1000)] + [int] $DelayMilliseconds = 25, + + [switch] $NoEnter +) + +$ErrorActionPreference = 'Stop' + +$map = @{ + ' ' = 'spc'; ':' = 'shift-semicolon'; ';' = 'semicolon' + '\' = 'backslash'; '|' = 'shift-backslash'; '/' = 'slash'; '?' = 'shift-slash' + '.' = 'dot'; '>' = 'shift-dot'; ',' = 'comma'; '<' = 'shift-comma' + '-' = 'minus'; '_' = 'shift-minus'; '=' = 'equal'; '+' = 'shift-equal' + '[' = 'bracket_left'; '{' = 'shift-bracket_left' + ']' = 'bracket_right'; '}' = 'shift-bracket_right' + "'" = 'apostrophe'; '"' = 'shift-apostrophe'; '`' = 'grave_accent'; '~' = 'shift-grave_accent' + '!' = 'shift-1'; '@' = 'shift-2'; '#' = 'shift-3'; '$' = 'shift-4'; '%' = 'shift-5' + '^' = 'shift-6'; '&' = 'shift-7'; '*' = 'shift-8'; '(' = 'shift-9'; ')' = 'shift-0' +} + +function ConvertTo-QemuKey([char] $Character) { + $text = [string] $Character + if ($map.ContainsKey($text)) { return $map[$text] } + if ([char]::IsLetter($Character)) { + $letter = [char]::ToLowerInvariant($Character) + if ([char]::IsUpper($Character)) { return "shift-$letter" } + return [string] $letter + } + if ([char]::IsDigit($Character)) { return $text } + throw "Unsupported QEMU key character: '$Character'" +} + +$client = [System.Net.Sockets.TcpClient]::new([System.Net.Sockets.AddressFamily]::InterNetwork) +try { + $client.Connect([System.Net.IPAddress]::Parse('127.0.0.1'), 4444) + $stream = $client.GetStream() + $writer = [System.IO.StreamWriter]::new($stream, [System.Text.Encoding]::ASCII, 1024, $true) + try { + $writer.NewLine = "`r`n" + $writer.AutoFlush = $true + Start-Sleep -Milliseconds 100 + + foreach ($char in $Text.ToCharArray()) { + $writer.WriteLine("sendkey $(ConvertTo-QemuKey $char)") + Start-Sleep -Milliseconds $DelayMilliseconds + } + if (-not $NoEnter) { $writer.WriteLine('sendkey ret') } + Start-Sleep -Milliseconds $DelayMilliseconds + } + finally { + $writer.Dispose() + } +} +finally { + $client.Dispose() +} diff --git a/.qemu/serial-relay.mjs b/.qemu/serial-relay.mjs new file mode 100644 index 0000000..de44160 --- /dev/null +++ b/.qemu/serial-relay.mjs @@ -0,0 +1,110 @@ +import net from "node:net"; + +const HOST = "127.0.0.1"; +const QEMU_PORT = 5556; +const TOOL_PORT = 5555; +const OBSERVER_PORT = 5557; + +let qemu = null; +let controller = null; +const observers = new Set(); + +function safeWrite(socket, data) { + if (socket && !socket.destroyed && socket.writable) socket.write(data); +} + +function decodeHex(hex) { + if (!hex || hex.length % 2 || !/^[0-9a-f]+$/i.test(hex)) return null; + return Buffer.from(hex, "hex").toString("utf8"); +} + +function visible(text) { + const shortened = text.length > 400 ? `${text.slice(0, 400)}…` : text; + return shortened + .replace(/\r\n?/g, "\n") + .replace(/[^\x20-\x7E\n]/g, (char) => `[0x${char.charCodeAt(0).toString(16).padStart(2, "0")}]`) + .replace(/\n/g, "\r\n "); +} + +function formatLine(source, line) { + if (!line) return null; + if (line === "PING" || line === "QUIT" || line === "DOSAGENT READY") return `${source} ${line}`; + + const [command, ...args] = line.split(" "); + if (["READ", "LIST"].includes(command) && args[0]) { + return `${source} ${command} ${visible(decodeHex(args[0]) ?? args[0])}${args[1] ? ` @${args[1]}` : ""}`; + } + if (command === "WRITE" && args.length >= 3) { + const path = decodeHex(args[0]) ?? args[0]; + const body = decodeHex(args.slice(2).join(" ")) ?? args.slice(2).join(" "); + return `${source} WRITE ${visible(path)} (${args[1] === "T" ? "replace" : "append"}) ${visible(body)}`; + } + if (command === "EXEC" && args[0]) return `${source} EXEC ${visible(decodeHex(args.join(" ")) ?? args.join(" "))}`; + if (command === "ERR" && args[0]) return `${source} ERROR ${visible(decodeHex(args.join(" ")) ?? args.join(" "))}`; + if (command === "OK") { + const singlePayload = args.length === 1 ? decodeHex(args[0]) : null; + const payload = singlePayload ?? (args.length > 1 ? decodeHex(args.slice(1).join(" ")) : null); + const status = singlePayload !== null ? "" : (args[0] ? ` (${args[0]})` : ""); + return `${source} OK${status}${payload !== null ? ` ${visible(payload)}` : ""}`; + } + return `${source} ${line}`; +} + +const traceBuffers = new Map(); +function trace(source, data) { + const pending = (traceBuffers.get(source) ?? "") + data.toString("ascii"); + const parts = pending.split(/\r?\n/); + traceBuffers.set(source, parts.pop()); + for (const line of parts) { + const formatted = formatLine(source, line); + if (formatted) for (const observer of observers) safeWrite(observer, `${formatted}\r\n`); + } +} + +const qemuServer = net.createServer((socket) => { + if (qemu && !qemu.destroyed) { + socket.end(); + return; + } + qemu = socket; + socket.setNoDelay(true); + socket.on("data", (data) => { + safeWrite(controller, data); + trace("DOS -> Pi", data); + }); + socket.on("close", () => { + if (qemu === socket) qemu = null; + if (controller && !controller.destroyed) controller.destroy(); + }); + socket.on("error", () => {}); +}); + +const toolServer = net.createServer((socket) => { + if (controller && !controller.destroyed) { + socket.end("ERR 53455249414C20434F4E54524F4C4C45522042555359\r\n"); + return; + } + controller = socket; + socket.setNoDelay(true); + socket.on("data", (data) => { + safeWrite(qemu, data); + trace("Pi -> DOS", data); + }); + socket.on("close", () => { + if (controller === socket) controller = null; + }); + socket.on("error", () => {}); +}); + +const observerServer = net.createServer((socket) => { + observers.add(socket); + socket.setNoDelay(true); + socket.write("DOS serial observer - decoded protocol (read-only)\r\n"); + socket.on("data", () => {}); + socket.on("close", () => observers.delete(socket)); + socket.on("error", () => observers.delete(socket)); +}); + +qemuServer.listen(QEMU_PORT, HOST); +toolServer.listen(TOOL_PORT, HOST); +observerServer.listen(OBSERVER_PORT, HOST); diff --git a/.qemu/setup.ps1 b/.qemu/setup.ps1 new file mode 100644 index 0000000..58b944e --- /dev/null +++ b/.qemu/setup.ps1 @@ -0,0 +1,26 @@ +param() + +$ErrorActionPreference = 'Stop' +$qemuImg = Get-Command qemu-img.exe -ErrorAction Stop +$disk = Join-Path $PSScriptRoot 'freedos.qcow2' +$archive = Join-Path $PSScriptRoot 'FD14-LiveCD.zip' +$iso = Join-Path $PSScriptRoot 'FD14LIVE.iso' +$url = 'https://www.ibiblio.org/pub/micro/pc-stuff/freedos/files/distributions/1.4/FD14-LiveCD.zip' + +if (-not (Test-Path -LiteralPath $disk)) { + & $qemuImg.Source create -f qcow2 $disk 2G + if ($LASTEXITCODE -ne 0) { throw "qemu-img failed with exit code $LASTEXITCODE." } +} + +if (-not (Test-Path -LiteralPath $iso)) { + if (-not (Test-Path -LiteralPath $archive)) { + Invoke-WebRequest -Uri $url -OutFile $archive + } + Expand-Archive -LiteralPath $archive -DestinationPath $PSScriptRoot -Force +} + +if (-not (Test-Path -LiteralPath $iso)) { + throw 'FreeDOS ISO extraction failed.' +} + +Get-Item -LiteralPath $disk,$iso | Select-Object Name,Length,LastWriteTime diff --git a/.qemu/share/dosagent.c b/.qemu/share/dosagent.c new file mode 100644 index 0000000..01095b4 --- /dev/null +++ b/.qemu/share/dosagent.c @@ -0,0 +1,240 @@ +#include +#include +#include +#include +#include +#include + +#define LINE_SIZE 9000 +#define CHUNK_SIZE 4096 + +static char linebuf[LINE_SIZE]; +static unsigned char data[CHUNK_SIZE + 1]; + +#define COM1_BASE 0x3F8 + +static void serial_init(void) { + outp(COM1_BASE + 1, 0x00); + outp(COM1_BASE + 3, 0x80); + outp(COM1_BASE + 0, 12); + outp(COM1_BASE + 1, 0); + outp(COM1_BASE + 3, 0x03); + outp(COM1_BASE + 2, 0xC7); + outp(COM1_BASE + 4, 0x0B); +} + +static int console_break_requested(void) { + unsigned short key = _bios_keybrd(_KEYBRD_READY); + if ((key & 0xFF) != 3) return 0; + _bios_keybrd(_KEYBRD_READ); + return 1; +} + +static int serial_getc(void) { + while ((inp(COM1_BASE + 5) & 0x01) == 0) { + if (console_break_requested()) return -1; + } + return inp(COM1_BASE); +} + +static void serial_putc(int c) { + while ((inp(COM1_BASE + 5) & 0x20) == 0) { } + outp(COM1_BASE, c); +} + +static void serial_write(const char *text) { + while (*text) serial_putc((unsigned char)*text++); +} + +static int serial_readline(char *buffer, int cap) { + int used = 0; + int c; + for (;;) { + c = serial_getc(); + if (c < 0) return -1; + if (c == '\r' || c == '\n') { + if (used == 0) continue; + buffer[used] = '\0'; + return used; + } + if (used < cap - 1) buffer[used++] = (char)c; + } +} + +static int hexval(int c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return -1; +} + +static int decode_hex(const char *src, unsigned char *dst, int cap) { + int n = 0; + int hi, lo; + while (*src && src[1]) { + if (n >= cap) return -1; + hi = hexval((unsigned char)src[0]); + lo = hexval((unsigned char)src[1]); + if (hi < 0 || lo < 0) return -1; + dst[n++] = (unsigned char)((hi << 4) | lo); + src += 2; + } + return *src ? -1 : n; +} + +static void print_hex(const unsigned char *src, unsigned count) { + static const char digits[] = "0123456789ABCDEF"; + unsigned i; + for (i = 0; i < count; ++i) { + serial_putc(digits[src[i] >> 4]); + serial_putc(digits[src[i] & 15]); + } +} + +static void ok_data(const unsigned char *src, unsigned count) { + serial_write("OK "); + print_hex(src, count); + serial_write("\r\n"); +} + +static void error_text(const char *message) { + serial_write("ERR "); + print_hex((const unsigned char *)message, strlen(message)); + serial_write("\r\n"); +} + +static int decode_path(const char *hex, char *path, int cap) { + int n = decode_hex(hex, (unsigned char *)path, cap - 1); + if (n < 0) return 0; + path[n] = '\0'; + return 1; +} + +static void command_read(char *args) { + char path[260]; + char *offset_text = strchr(args, ' '); + FILE *file; + long offset; + size_t count; + int eof; + + if (!offset_text) { error_text("READ requires path and offset"); return; } + *offset_text++ = '\0'; + if (!decode_path(args, path, sizeof(path))) { error_text("Invalid path encoding"); return; } + offset = atol(offset_text); + file = fopen(path, "rb"); + if (!file) { error_text("Cannot open file"); return; } + if (fseek(file, offset, SEEK_SET) != 0) { fclose(file); error_text("Cannot seek file"); return; } + count = fread(data, 1, CHUNK_SIZE, file); + eof = feof(file) ? 1 : 0; + if (count < CHUNK_SIZE) eof = 1; + fclose(file); + sprintf(linebuf, "OK %d ", eof); + serial_write(linebuf); + print_hex(data, count); + serial_write("\r\n"); +} + +static void command_write(char *args) { + char path[260]; + char *mode_text = strchr(args, ' '); + char *payload; + FILE *file; + int count; + + if (!mode_text) { error_text("WRITE requires path, mode and data"); return; } + *mode_text++ = '\0'; + payload = strchr(mode_text, ' '); + if (!payload) { error_text("WRITE requires data"); return; } + *payload++ = '\0'; + if (!decode_path(args, path, sizeof(path))) { error_text("Invalid path encoding"); return; } + count = decode_hex(payload, data, CHUNK_SIZE); + if (count < 0) { error_text("Invalid data encoding"); return; } + file = fopen(path, mode_text[0] == 'A' ? "ab" : "wb"); + if (!file) { error_text("Cannot write file"); return; } + if (count && fwrite(data, 1, count, file) != (size_t)count) { + fclose(file); error_text("Short write"); return; + } + fclose(file); + ok_data((const unsigned char *)"", 0); +} + +static void command_list(char *args) { + char path[260]; + char pattern[300]; + char output[CHUNK_SIZE]; + struct find_t found; + unsigned used = 0; + int rc; + + if (!decode_path(args, path, sizeof(path))) { error_text("Invalid path encoding"); return; } + strcpy(pattern, path); + if (pattern[0] && pattern[strlen(pattern) - 1] != '\\' && pattern[strlen(pattern) - 1] != '/') strcat(pattern, "\\"); + strcat(pattern, "*.*"); + rc = _dos_findfirst(pattern, _A_NORMAL | _A_RDONLY | _A_HIDDEN | _A_SYSTEM | _A_SUBDIR | _A_ARCH, &found); + while (rc == 0) { + char entry[100]; + int len; + if (strcmp(found.name, ".") && strcmp(found.name, "..")) { + sprintf(entry, "%s\t%lu\t%s\n", found.name, found.size, (found.attrib & _A_SUBDIR) ? "DIR" : "FILE"); + len = strlen(entry); + if (used + len >= sizeof(output)) break; + memcpy(output + used, entry, len); + used += len; + } + rc = _dos_findnext(&found); + } + ok_data((const unsigned char *)output, used); +} + +static void command_exec(char *args) { + char command[700]; + char shell_command[800]; + const char *temp_path = "C:\\PIEXEC.TMP"; + FILE *file; + int n, exit_code; + size_t count; + + n = decode_hex(args, (unsigned char *)command, sizeof(command) - 1); + if (n < 0) { error_text("Invalid command encoding"); return; } + command[n] = '\0'; + sprintf(shell_command, "COMMAND.COM /C %s > %s", command, temp_path); + exit_code = system(shell_command); + file = fopen(temp_path, "rb"); + count = file ? fread(data, 1, CHUNK_SIZE, file) : 0; + if (file) fclose(file); + remove(temp_path); + sprintf(linebuf, "OK %d ", exit_code); + serial_write(linebuf); + print_hex(data, count); + serial_write("\r\n"); +} + +int main(void) { + char *command; + char *args; + + serial_init(); + cprintf("\r\n[DOSAGENT] Ready on COM1. Press Ctrl+C here to stop.\r\n"); + serial_write("DOSAGENT READY\r\n"); + for (;;) { + if (serial_readline(linebuf, sizeof(linebuf)) < 0) { + cprintf("\r\n[DOSAGENT] Stopped from VGA console.\r\n"); + break; + } + command = linebuf; + args = strchr(command, ' '); + if (args) *args++ = '\0'; else args = command + strlen(command); + + cprintf("[DOSAGENT] %s\r\n", command); + + if (!strcmp(command, "PING")) ok_data((const unsigned char *)"PONG", 4); + else if (!strcmp(command, "READ")) command_read(args); + else if (!strcmp(command, "WRITE")) command_write(args); + else if (!strcmp(command, "LIST")) command_list(args); + else if (!strcmp(command, "EXEC")) command_exec(args); + else if (!strcmp(command, "QUIT")) { ok_data((const unsigned char *)"BYE", 3); break; } + else error_text("Unknown command"); + } + return 0; +} diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..746cc2c --- /dev/null +++ b/SPEC.md @@ -0,0 +1,623 @@ +# Ferro 언어 명세 v0.1 + +DOS용 시스템 프로그래밍 언어. C만큼 빠르고, 메모리 안전성을 함수 단위 지역 검사만으로 보장한다. +파일 확장자 `.fe`, 컴파일러 이름 `fec`, 심볼 파일 `.fei`. + +이 문서는 언어 명세 + 컴파일러 구현 지시서를 겸한다. 구현 중 애매한 부분은 §1 철학과 §5 소유권 규칙을 기준으로 결정한다. + +--- + +## 1. 설계 철학 + +1. **안전은 기본, 위험은 명시.** 기본 코드는 메모리 안전(널 역참조, 버퍼 오버런, use-after-free, 이중 해제 불가). 위험한 연산은 `unsafe {}` 블록 안에서만. +2. **전역 분석 금지.** 모든 검사(타입, 소유권, 참조)는 함수 하나만 보고 완결되어야 한다. 이 제약이 라이프타임 표기를 없애고, 640KB 머신에서 셀프호스팅을 가능하게 한다. +3. **숨은 비용 없음.** 힙 할당, 복사, 소멸자 호출, 형변환이 전부 소스에 보인다. GC 없음, 예외 없음, 암묵 변환 없음. +4. **읽히는 문법.** `이름: 타입` 순서, 좌→우 파싱, LL(1) 재귀하강으로 처리 가능. +5. **작게 시작.** 기능을 넣기 전에 뺄 이유를 먼저 찾는다. 뺀 것과 그 대체 수단은 §13에 기록한다. +6. **기존 도구체인 재사용.** 링커, `.OBJ`/`.LIB`/`.EXE` 포맷, DPMI 익스텐더를 새로 만들지 않는다. + +--- + +## 2. 타깃 + +| | `bits16` | `bits32` | +|---|---|---| +| CPU/모드 | 8086 리얼모드 | 386 보호모드 플랫 (DPMI) | +| `usize`/`isize` | 16비트 | 32비트 | +| 포인터 크기 | near 2B / far 4B | 4B (far 없음) | +| 메모리 모델 | small, large | flat | +| 시스템 호출 | INT 21h 직접 | DPMI 서비스 + 실모드 콜백 | + +- CLI: `fec main.fe --target=bits16|bits32 [--model=small|large]` +- 소스 분기: `comptime if @bits == 16 { ... } else { ... }` +- `bits32`에서 `far` 키워드를 쓰면 컴파일 에러. +- 표준 라이브러리는 코어 공용, `sys` 유닛만 타깃별 구현. + +--- + +## 3. 어휘 구조 + +- 식별자: `[A-Za-z_][A-Za-z0-9_]*`. 대소문자 구분. +- 주석: `//` 줄 끝까지, `/* */` **중첩 허용**. +- 정수 리터럴: `123`, `0xFF`, `0b1010`, `0o17`, 자릿수 구분 `1_000_000`. +- 문자 리터럴: `'a'`, `'\n'`, `'\x41'` → 타입 `char`. +- 문자열 리터럴: `"abc"` → 타입 `str`. NUL 종료 아님. 이스케이프는 문자 리터럴과 동일. 인접 리터럴 자동 연결 없음. +- 불린: `true`, `false`. 옵셔널 널: `null`. +- 세미콜론 필수. 블록 중괄호 필수(단문 `if`도 `{}` 필요). + +**예약어:** +``` +unit import pub fn struct enum error const static var let mut +if else while for in match return break continue defer +unsafe comptime asm try catch as extern interrupt far +true false null self Self type +``` + +--- + +## 4. 타입 시스템 + +### 4.1 기본 타입 + +- 정수: `i8 i16 i32 u8 u16 u32 usize isize` +- `bool` (1바이트, 정수와 상호 변환 없음) +- `char` (u8과 크기 같지만 별개 타입) +- `void` (반환 타입으로만) +- `type` (comptime 파라미터에서만, §9) + +**정수 규칙:** +- 서로 다른 정수 타입 간 암묵 변환 없음. `as`로 명시. +- `as`는 절단/부호확장을 수행하며 값 손실을 검사하지 않는다. +- `+ - * / %`는 검사 빌드에서 오버플로 시 트랩. `+% -% *%`는 랩어라운드(항상 무검사). +- `/`, `%`의 0 나눗셈은 항상 트랩(검사 빌드 여부 무관, CPU가 트랩함). +- 시프트 `<< >>`: 우변은 `u8`. 시프트 양이 비트폭 이상이면 검사 빌드에서 트랩. +- 비트 연산 `& | ^ ~`는 같은 타입끼리만. + +### 4.2 복합 타입 + +| 문법 | 의미 | 표현 | +|---|---|---| +| `[N]T` | 배열, 값 타입, N은 컴파일타임 상수 | `N * sizeof(T)` | +| `[]T` | 슬라이스 (참조성, §5 R4 적용) | `(ptr, len)` | +| `str` | `[]u8` 불변 별칭 | `(ptr, len)` | +| `^T` | 소유 포인터 (힙, 단일 소유자) | 포인터 | +| `&T` | 공유 참조 | 포인터 | +| `&mut T` | 배타 참조 | 포인터 | +| `*T` | raw 포인터 (`unsafe`에서만 역참조) | 포인터 | +| `far ^T`, `far *T`, `far &T` | far 포인터 (`bits16` 전용) | 4바이트 | +| `?T` | 옵셔널 | 널 표현 가능 타입은 크기 동일, 아니면 `(bool, T)` | +| `E!T` / `!T` | 에러 유니온 (`!T`는 기본 에러 집합) | `(u16 err, T val)` | +| `fn(A, B) -> R` | 함수 포인터 | 포인터 | + +- **배열은 포인터로 붕괴하지 않는다.** 함수에 넘기려면 `arr[..]`로 슬라이스를 만들거나 `&arr` / `^[N]T`를 쓴다. +- 슬라이싱: `arr[..]`, `arr[a..b]`(반개구간, 경계 검사), `arr[a..]`, `slice[a..b]`. +- 소유 슬라이스가 필요하면 `^[]T`(길이 있는 힙 버퍼)를 쓴다. `mem.alloc_slice(T, n)`가 반환. + +### 4.3 구조체 + +```fe +pub struct Point { + x: i32, + y: i32, + + pub fn new(x: i32, y: i32) -> Point { return Point{ x: x, y: y }; } + pub fn len2(self: &Self) -> i32 { return self.x*self.x + self.y*self.y; } + pub fn shift(self: &mut Self, dx: i32) { self.x += dx; } +} +``` + +- 리터럴: `Point{ x: 1, y: 2 }`. 모든 필드 명시 필수(기본값 없음). +- 메서드는 struct 블록 안에 정의. 첫 파라미터가 `self: Self | &Self | &mut Self`면 메서드. +- `x.f(y)`는 `Point.f(x, y)`의 설탕. 자동 참조 취함(`x.shift(1)`은 `Point.shift(&mut x, 1)`). +- `Self`는 자기 타입의 별칭. +- 필드 레이아웃은 선언 순서. 정렬은 타깃 규칙(`bits16`은 1바이트 정렬, `bits32`는 자연 정렬). `packed struct`로 정렬 강제 해제. +- 소멸자: `fn drop(self: &mut Self)`를 정의하면 스코프 종료 시 자동 호출(§5 R3). + +### 4.4 열거형 (태그드 유니온) + +```fe +pub enum Shape { + Empty, + Circle(i32), + Rect{ w: i32, h: i32 }, +} +``` + +- 표현: `struct { u8 tag; union {...} payload; }`. 배리언트 256개 초과 시 `u16` 태그. +- 페이로드 없는 배리언트만 있는 열거형은 정수처럼 취급되며 `as u8` 가능. +- 생성: `Shape.Circle(5)`, `Shape.Rect{ w: 3, h: 4 }`, `Shape.Empty`. +- 해체는 `match` 또는 `if let`으로만. 직접 필드 접근 불가. + +### 4.5 옵셔널 + +```fe +var p: ?^Node = null; +if let Some(node) = p { node.value = 1; } // node: &mut Node (p가 mut일 때) +let v = p.?; // null이면 트랩 +let v = p orelse default_node; // null이면 우변 +``` + +- `?T`에서 T가 `^T`, `&T`, `*T`, `fn`이면 널 포인터를 널 표현으로 사용(크기 증가 없음). +- 검사 없이 역참조 불가. `p.^`는 컴파일 에러, `p.?.^`가 필요. + +### 4.6 에러 + +```fe +pub error IoError { + NotFound = 1, + Denied = 2, + Eof = 3, +} + +fn read_all(path: str) -> IoError!^[]u8 { + let f = try io.open(path); // 에러면 즉시 반환 + defer f.close(); + let n = f.size() catch |e| { return e; }; + ... +} +``` + +- `error` 선언은 `u16` 코드 집합. 코드 0은 "성공" 예약, 사용 불가. +- `E!T` 함수만 `try`/`catch` 사용 가능. +- `try e`: 에러면 현재 함수에서 즉시 반환(현재 함수도 에러 유니온을 반환해야 함). +- `e catch |x| { ... }`: 블록은 값을 만들거나 `return`/`break`로 탈출. +- `e catch default_value`: 짧은 형태. +- 서로 다른 error 타입 간 자동 변환 없음. `!T`(기본 에러 집합 `core.Error`)로 통일하거나 명시 매핑. +- 에러는 값이다. 언와인딩, 스택 추적, 소멸자 이외의 자동 정리 없음. + +### 4.7 타입 동등성 + +이름 기반(nominal). 필드가 같아도 다른 이름이면 다른 타입. 별칭은 `const Alias = Type;`으로 만들며 완전 동일 취급. + +--- + +## 5. 소유권과 참조 — 핵심 규칙 + +이 절이 언어의 핵심이다. 모든 규칙은 **함수 하나만 보고** 검사된다. + +**R1 (단일 소유자).** 모든 값의 소유자는 정확히 하나. 변수 대입, 함수 인자 전달, 반환은 **이동(move)**이다. 이동된 변수는 이후 사용 시 컴파일 에러. + +**R2 (Copy 타입).** 다음은 이동 대신 복사된다: 정수, `bool`, `char`, raw 포인터 `*T`, 참조 `&T`, 함수 포인터, 그리고 모든 필드가 Copy이면서 `drop`이 없는 struct/enum/배열. `^T`와 `&mut T`는 Copy가 아니다. + +**R3 (소멸자, RAII).** `^T`는 소유자 스코프 종료 또는 재대입 시 `drop` 호출 후 해제. struct에 `fn drop(self: &mut Self)`가 있으면 그 값의 스코프 종료 시 자동 호출되며, 이어서 필드들의 drop이 선언 역순으로 호출된다. `drop`을 직접 호출하는 것은 컴파일 에러(`mem.destroy(x)` 사용). + +**R4 (참조는 2급 값).** `&T`, `&mut T`, `[]T`는 다음 위치에만 존재할 수 있다: +- 함수 파라미터 +- 지역 변수 (`let`/`var`) +- 표현식 안의 임시값 + +다음은 **컴파일 에러**다: +- struct/enum 필드의 타입 +- 배열/슬라이스의 원소 타입 +- 함수 반환 타입 (예외: R8) +- `^T`, `*T`의 대상 타입 +- 전역 변수의 타입 + +이 한 줄이 라이프타임 표기 전체를 불필요하게 만든다. + +**R5 (참조 수명).** 지역 참조 변수는 대상보다 오래 살 수 없다. R4 덕분에 대상은 항상 같은 함수의 지역 변수, 파라미터, 또는 전역이므로 스코프 중첩 확인만으로 검사된다. + +**R6 (배타성).** `&mut x`가 살아있는 동안 `x`에 대한 다른 참조 생성, 직접 읽기/쓰기, 이동이 금지된다. `&x`(공유)는 여러 개 동시 가능하지만 그동안 `x`에 쓰기/이동 금지. 참조의 생존 구간은 **참조 변수의 스코프 끝까지**(NLL 아님). 임시 참조(`f(&x)`)는 그 문장 끝까지. + +**R7 (참조 무효화).** 참조 대상이 이동되거나 재대입되면 그 참조는 이후 사용 시 에러. + +**R8 (반환값 예외).** 메서드는 `-> &T` / `-> &mut T`를 반환할 수 있다. 단, 첫 파라미터가 `&Self`/`&mut Self`이고 반환 참조는 그 self에서 파생된 것이어야 하며(컴파일러가 확인), **호출 결과는 그 문장 안에서만 사용 가능**하고 지역 변수에 바인딩할 수 없다. + +```fe +list.at(0).x = 5; // OK +let r = list.at(0); // 에러: 반환 참조를 바인딩 불가 +``` +장수하는 접근이 필요하면 인덱스(`usize`)나 핸들을 쓴다. + +**R9 (unsafe).** `unsafe {}` 안에서만 허용: raw 포인터 역참조, `*T` ↔ `^T`/`&T` 변환, `@ptr_cast`, `@seg_ptr`, `@volatile_*`, `@port_*`, `asm`, `*_unchecked` 함수, 전역 `var` 접근. R1~R8은 `unsafe` 안에서도 유지되며, raw 포인터를 경유해야만 우회된다. + +**R10 (전역).** `static`은 불변이며 컴파일타임 상수 초기화만 가능. `var` 전역은 허용되나 접근이 `unsafe`. 전역에 `^T`나 `drop` 있는 타입 금지. + +**R11 (그래프 구조).** 참조 순환이 필요한 자료구조는 아레나 + 인덱스 핸들로 표현한다. 표준 라이브러리 `mem.Arena`와 `u16`/`u32` 인덱스를 쓴다. + +--- + +## 6. 문법 + +### 6.1 EBNF + +``` +unit := 'unit' ident ';' import* decl* +import := 'import' ident ';' + +decl := ['pub'] (fn_decl | struct_decl | enum_decl | error_decl + | const_decl | global_decl) + +fn_decl := ['extern' string] ['interrupt'] 'fn' ident + '(' [param (',' param)*] ')' ['->' type] (block | ';') +param := ['comptime'] ident ':' type +struct_decl := ['packed'] 'struct' ident '{' field* fn_decl* '}' +field := ident ':' type ',' +enum_decl := 'enum' ident '{' variant (',' variant)* [','] '}' +variant := ident | ident '(' type ')' | ident '{' field* '}' +error_decl := 'error' ident '{' (ident '=' int ',')* '}' +const_decl := 'const' ident [':' type] '=' expr ';' +global_decl := ('static' | 'var') ident ':' type '=' expr ';' + +block := '{' stmt* '}' +stmt := 'let' ['mut'] ident [':' type] '=' expr ';' + | 'var' ident ':' type ['=' expr] ';' + | 'const' ident [':' type] '=' expr ';' + | lvalue ('=' | '+=' | '-=' | '*=' | '/=' | '%=' + | '&=' | '|=' | '^=' | '<<=' | '>>=') expr ';' + | if_stmt | while_stmt | for_stmt | match_stmt + | 'return' [expr] ';' | 'break' ';' | 'continue' ';' + | 'defer' block + | 'unsafe' block + | 'comptime' 'if' expr block ['else' (block | 'if' ...)] + | 'asm' '{' asm_body '}' + | expr ';' + +if_stmt := 'if' (expr | 'let' pattern '=' expr) block + ['else' (block | if_stmt)] +while_stmt := 'while' expr block +for_stmt := 'for' ident [',' ident] 'in' expr block +match_stmt := 'match' expr '{' arm+ '}' +arm := pattern '=>' (expr ';' | block) +pattern := ident // 배리언트, 페이로드 없음 + | ident '(' ident ')' // 튜플형 배리언트 바인딩 + | ident '{' ident (',' ident)* '}' // 필드형 배리언트 바인딩 + | 'Some' '(' ident ')' | 'None' + | int_literal | '_' + +type := ident ['.' ident] + | '?' type | '!' type | ident '!' type + | '^' type | '&' ['mut'] type | '*' type + | 'far' ('^' | '*' | '&' ['mut']) type + | '[' expr ']' type | '[' ']' type + | 'fn' '(' [type (',' type)*] ')' ['->' type] + | ident '(' type (',' type)* ')' // 제네릭 인스턴스 +``` + +### 6.2 표현식 우선순위 (낮음 → 높음) + +``` +1 orelse, catch +2 || +3 && +4 == != < <= > >= +5 | ^ +6 & +7 << >> +8 + - +% -% +9 * / % *% +10 단항: - ! ~ & &mut ^(주소아님) try +11 후위: .field .? .^ [i] [a..b] (args) as T +12 기본: literal, ident, '(' expr ')', struct_literal, @builtin(...) +``` + +- `&&`, `||`는 단축 평가. +- `as`는 후위 우선순위(단항보다 강함): `-x as i32`는 `-(x as i32)`. +- 비교 연산 체이닝 금지(`a < b < c`는 에러). + +### 6.3 빌트인 + +``` +@size_of(T) -> usize @align_of(T) -> usize +@bits -> comptime int @target -> comptime str +@ptr_cast(T, p) -> *T (unsafe) +@seg_ptr(seg: u16, off: u16) -> far *T (unsafe, bits16) +@port_in8(p) @port_in16(p) @port_out8(p,v) @port_out16(p,v) (unsafe) +@volatile_load(p) @volatile_store(p, v) (unsafe) +@trap() -> never @unreachable() -> never (unsafe) +@line() @file() // 진단용 +``` + +### 6.4 예제 + +```fe +unit vga; +import sys; + +const WIDTH: u16 = 320; +const HEIGHT: u16 = 200; + +pub fn set_mode13() { + unsafe { asm { mov ax, 0x0013; int 0x10; } } +} + +pub fn put_pixel(x: u16, y: u16, c: u8) { + if x >= WIDTH or y >= HEIGHT { return; } + unsafe { + let vram: far *u8 = @seg_ptr(0xA000, 0); + @volatile_store(vram + (y * WIDTH + x) as usize, c); + } +} +``` + +```fe +unit main; +import io; +import list; +import fmt; + +fn count_lines(path: str) -> !usize { + let f = try io.open(path, io.Read); + defer f.close(); + + var buf: [256]u8 = undefined; + var n: usize = 0; + while true { + let got = try f.read(buf[..]); + if got == 0 { break; } + for c in buf[0..got] { + if c.^ == '\n' { n += 1; } + } + } + return n; +} + +pub fn main() -> !void { + let n = count_lines("data.txt") catch |e| { + fmt.print_str("failed: "); + fmt.print_int(e as u16); + return e; + }; + fmt.print_int(n); +} +``` + +--- + +## 7. 의미론 세부 + +### 7.1 변수와 초기화 + +- `let`은 불변, `let mut`은 가변, `var`는 타입 명시 필수인 가변 선언. +- 모든 변수는 사용 전 초기화 필수(정적 검사). 명시적 미초기화는 `= undefined`(unsafe 아님, 단 읽기 전 쓰기 필수는 여전히 검사). +- 섀도잉 허용(같은 스코프에서 `let` 재선언). + +### 7.2 제어 흐름 + +- `for x in slice`: `x`는 `&T`(가변 슬라이스면 `&mut T`). 값 접근은 `x.^`. +- `for i, x in slice`: `i: usize`. +- `for i in a..b`: 정수 범위. +- 이 루프 형태들은 경계 검사를 생략한다(컴파일러가 안전을 보장). +- `while`은 `bool` 조건만. +- `match`는 **완전성 검사**. 모든 배리언트를 다루거나 `_` 필요. +- `break`/`continue`는 가장 안쪽 루프에만 적용(레이블 없음). +- `defer block`은 스코프 종료 시 역순 실행. 소멸자와 함께 선언 역순으로 병합 실행. `return`/`break`/에러 전파 경로에서도 실행. + +### 7.3 함수 호출 규약 + +- 기본: `bits32`는 cdecl, `bits16`은 타깃 C 컴파일러 기본. +- `extern "c" fn name(...) -> T;` — 본문 없이 선언, C 심볼과 링크. 이름 맹글링 없음. 인자/반환에 `^T`, 슬라이스, 에러 유니온 사용 불가(`*T`, `usize`만). +- `interrupt fn name()` — 모든 레지스터 보존 + `iret`. 파라미터/반환 없음. 주소는 `@as_far_fn(name)`으로 획득. +- 큰 struct(> 4바이트)는 숨은 포인터로 반환(C ABI 따름). + +### 7.4 검사와 트랩 + +트랩 발생 조건: 배열/슬라이스 경계 초과, 정수 오버플로, 0 나눗셈, `?T`의 `.?` 실패, `@trap()`. + +동작: `core.panic(msg: str, file: str, line: u32)` 호출 → 메시지 출력 → `sys.exit(3)`. 사용자가 `core.set_panic_handler`로 교체 가능. + +`--no-checks` 빌드에서 제거되는 것: 경계 검사, 오버플로 검사, `.?` 검사. +**절대 제거되지 않는 것:** 소유권/참조 검사, 옵셔널 타입 검사, `match` 완전성 — 전부 컴파일타임이므로. + +### 7.5 comptime + +- `const` 선언의 초기값은 컴파일타임 평가(정수 연산, `@size_of`, `@bits`, 다른 const). +- `comptime if`는 평가되지 않는 분기를 **파싱은 하되 타입 검사/코드 생성하지 않는다**(타깃별 분기용). +- 함수의 `comptime` 파라미터는 §9 제네릭. +- 재귀 평가 깊이 제한 256, 초과 시 에러. + +--- + +## 8. 유닛과 빌드 + +- 파일 하나 = 유닛 하나. 첫 줄은 `unit <이름>;`이며 파일명과 일치해야 함. +- `import bar;` → 같은 검색 경로의 `bar.fe`. 접근은 `bar.name`. +- `pub` 붙은 선언만 외부 노출. 구조체 필드도 개별 `pub` 필요. +- 순환 import 금지(에러). +- 유닛 컴파일 시 `.fei` 생성: pub 선언 시그니처, 타입 레이아웃, 제네릭 본문 토큰. 소스 해시가 같으면 재컴파일 생략. +- 검색 경로: `-I `, 기본은 현재 디렉터리 + `/std`. + +``` +fec main.fe --target=bits32 -o game.exe +fec main.fe --target=bits16 --model=large --no-checks -o game.exe +fec main.fe --emit-c -o out/ # 트랜스파일 결과만 +fec --dump-ast main.fe +``` + +--- + +## 9. 제네릭 + +`comptime` 파라미터 기반 모노모피제이션. + +```fe +pub struct List(T) { + items: ^[]T, + len: usize, + + pub fn new() -> List(T) { ... } + pub fn push(self: &mut Self, v: T) -> !void { ... } + pub fn at(self: &Self, i: usize) -> &T { ... } // R8 적용 + pub fn drop(self: &mut Self) { ... } +} + +fn max(comptime T: type, a: T, b: T) -> T { + if a > b { return a; } + return b; +} + +let m = max(i32, 3, 7); +var xs: List(u8) = List(u8).new(); +``` + +- 인스턴스화 시 타입 인자를 대입해 본문을 재검사하고 코드를 생성한다. 인스턴스 캐시 키는 `(선언, 타입 인자 목록)`. +- 제약(trait bound) 없음. 본문에서 쓰는 연산이 그 타입에 없으면 **인스턴스화 시점에** 에러(에러 메시지에 인스턴스화 위치를 표시할 것). +- `.fei`에 제네릭 본문을 토큰 스트림으로 저장, 사용처에서 재파싱. +- 재귀적 인스턴스화 깊이 제한 32. + +--- + +## 10. 표준 라이브러리 (최소 집합) + +- **core**: `panic`, `set_panic_handler`, `Error`(기본 에러 집합), `assert`. +- **mem**: `create(T) -> !^T`, `destroy(p)`, `alloc_slice(T, n) -> !^[]T`, `copy(dst, src)`, `set(dst, v)`, `Arena{ init, alloc, reset, drop }`. +- **str**: `eq`, `find`, `starts_with`, `split_at`, `parse_int`, `trim`, `to_cstr(buf, s)`, `from_cstr(p)`. +- **list**: `List(T)`. +- **map**: `Map(K, V)` (오픈 어드레싱, `K`는 정수/str). +- **fmt**: `print_str`, `print_int`, `print_hex`, `format(buf, ...)`. 제네릭 도입 후 `print(fmt, args)` 추가. +- **io**: `File{ open, create, read, write, seek, size, close(=drop) }`, `stdin`, `stdout`, `stderr`. +- **sys**: `exit`, `args`, `env`, `ticks`, `int21(regs)`, `dpmi_*`(bits32), `port_in/out`, `far_copy`(bits16). + +`io.File`은 `drop`에서 핸들을 닫는다. 이중 닫기는 소유권 규칙이 막는다. + +--- + +## 11. 컴파일러 구현 + +### 11.1 부트스트랩 전략 + +1. **컴파일러 A** — C89로 작성. Ferro → C 트랜스파일러. 호스트는 현대 PC 또는 DOS. 출력 C는 DJGPP(gcc, bits32) / Open Watcom(bits16, bits32) / Borland C(bits16)로 컴파일. +2. **컴파일러 B** — Ferro로 A와 동일 구조를 재작성. A로 빌드. +3. **셀프호스팅** — B로 B를 빌드. 그 결과로 다시 B를 빌드해 출력이 바이트 동일(fixpoint)하면 완료. A 폐기. +4. **네이티브 백엔드** — B에 386 코드 생성기 추가, 이후 8086 코드 생성기. + +A는 버릴 코드다. 최적화하지 말고 B를 컴파일할 수 있는 최소 언어 부분집합만 지원한다. + +### 11.2 파이프라인 + +``` +소스 → lexer → parser(AST) → resolve(이름/import) → check(타입) + → own(소유권·참조) → lower(소멸자/defer/try/for 전개 → LIR) + → emit_c(C 소스) [또는 emit_x86] +``` + +각 단계는 실패해도 가능한 한 진행해 에러를 모아 보고한다(문장 단위 복구). + +### 11.3 디렉터리 + +``` +fec/ + src/ + lexer.c/h 토큰화. 위치(파일, 줄, 열) 보존. + ast.c/h 노드 정의, 아레나 할당자. + parser.c/h LL(1) 재귀하강. 에러 복구는 다음 ';' 또는 '}'까지 스킵. + types.c/h 타입 인터닝(포인터 비교로 동등성), 레이아웃 계산(타깃별). + resolve.c/h 스코프 체인, 심볼 테이블, import, .fei 읽기/쓰기. + check.c/h 타입 검사, 리터럴 타입 결정, match 완전성, R4 위치 검사. + own.c/h §11.5 알고리즘. + lower.c/h AST → LIR. 소멸자/defer 삽입, try/catch/for/메서드 호출 전개. + emit_c.c/h LIR → C. §11.4 규칙. + generic.c/h 인스턴스 캐시, 토큰 재파싱. + driver.c CLI, 유닛 의존 순서, .fei 캐시, 외부 C 컴파일러 호출. + rt/ 런타임 (C): trap, 힙, 슬라이스 헬퍼, DPMI/INT21 shim + std/ 표준 라이브러리 (.fe) + tests/ §12 +``` + +### 11.4 C 방출 규칙 + +| Ferro | C | +|---|---| +| `i16`, `u32` 등 | `int16_t`, `uint32_t` (`` 없으면 자체 typedef) | +| `usize` | `uint16_t`(bits16) / `uint32_t`(bits32) | +| `bool` | `unsigned char` | +| `^T`, `*T` | `T*` | +| `&T` | `const T*` | +| `&mut T` | `T*` | +| `far X` | `__far X` (Watcom/Borland), bits32는 무시 | +| `[N]T` | `struct { T a[N]; }` (값 의미론 유지, 붕괴 방지) | +| `[]T` | `typedef struct { T* p; fe_usize n; } fe_slice_T;` | +| `?T` (포인터류) | 원래 포인터, null 사용 | +| `?T` (그 외) | `struct { unsigned char has; T v; }` | +| `E!T` | `struct { uint16_t e; T v; }`, `!void`는 `uint16_t` | +| struct | `struct fe__` | +| enum | `struct { uint8_t tag; union { ... } u; }` | +| 함수 | `fe__`, 메서드는 `fe___` | +| 제네릭 인스턴스 | `fe____<타입인자맹글>` | + +세부: +- **오버플로 검사**: `fe_add_i16(a, b, LINE)` 인라인 함수. `--no-checks`면 매크로가 `((a)+(b))`로 축약. +- **경계 검사**: `fe_idx_T(s, i, LINE)` → `(i < s.n ? s.p[i] : (fe_trap_bounds(LINE), s.p[0]))`. `for` 루프는 직접 인덱스. +- **`try`**: `{ Ttmp t = expr; if (t.e) return (RetT){ t.e }; }` 후 `t.v` 사용. defer/소멸자가 있으면 return 전에 정리 코드 삽입. +- **`catch`**: `t.e`가 참일 때 블록 실행, 바인딩 변수는 `t.e`. +- **`defer`/소멸자**: lower 단계에서 스코프 종료 지점(정상 흐름, `return`, `break`, `continue`, `try` 전파)마다 역순 호출을 명시적으로 삽입. C의 goto 라벨을 써도 되고 복제해도 된다(A는 복제, B는 goto 권장). +- **조건부 이동**: 이동 여부가 분기에 따라 다르면 `unsigned char fe_live_ = 1;` 플래그 삽입, drop 전에 검사. +- **`match`**: `switch (x.tag)`. 페이로드 바인딩은 지역 변수로 복사 또는 포인터. +- **`asm`**: Intel 문법으로 고정 저장. Watcom/Borland는 그대로, gcc는 `__asm__(".intel_syntax noprefix\n" ...)`로 감싼다. +- **방출 순서**: typedef 전방선언 → struct 정의(의존 위상 정렬) → 전역 → 함수 프로토타입 → 함수 본문. +- 유닛 하나당 `.c` 하나, `.fei`에서 필요한 부분은 `.h`로 생성. + +### 11.5 own.c 알고리즘 + +함수 단위. 각 지역 변수/파라미터에 상태: +``` +Uninit | Owned | Moved | MaybeMoved | Shared(n) | Exclusive +``` + +1. AST를 문장 순서로 순회하며 상태 전이. +2. 표현식 평가 시 lvalue 사용을 분류: 읽기 / 이동 / `&` 대여 / `&mut` 대여 / 쓰기. +3. 이동: `Owned → Moved`. `Moved`/`MaybeMoved` 사용 시 에러(최초 이동 위치를 에러에 표시). +4. `&x`: `Owned → Shared(n+1)`. `&mut x`: `Owned → Exclusive`. 해제는 참조 변수의 스코프 끝(임시 참조는 문장 끝). +5. `Shared`/`Exclusive` 상태에서 쓰기/이동/재대여 시 에러(R6). +6. **분기 합류**: `if`/`match`의 각 브랜치를 독립 상태로 계산 후 병합. `Owned` + `Moved` → `MaybeMoved`(사용 에러, drop은 런타임 플래그). +7. **루프**: 본문을 2회 순회. 1회차 종료 상태를 진입 상태와 병합해 2회차 실행, 상태가 수렴하지 않으면(예: 첫 반복에서 이동) 에러. +8. R4 위반(참조를 필드/반환/힙에 저장)은 own이 아니라 check 단계에서 **타입만 보고** 거부한다. +9. R8(메서드 참조 반환)은 호출 결과를 바인딩하려는 시도를 check에서 거부. + +에러 메시지 형식: `file:line:col: error: <설명>` + 관련 위치 `file:line:col: note: <최초 이동/대여 위치>`. + +### 11.6 마일스톤 + +| # | 내용 | 완료 기준 | +|---|---|---| +| M1 | lexer, parser, AST 덤프 | `--dump-ast`가 std 소스 전체를 파싱 | +| M2 | 타입 검사 + C 방출: 정수, 함수, if, while | bits32 hello world 실행 | +| M3 | struct, enum, match, 배열, 슬라이스, 경계 검사, str | 문자열 처리 예제 통과 | +| M4 | `^T`, drop, defer, 이동 검사 | 누수/이중해제 테스트 통과 | +| M5 | `&`, `&mut`, 배타성 검사 (own.c 전체) | R1~R8 실패 테스트 통과 | +| M6 | `?T`, `E!T`, try/catch | io 유닛 동작 | +| M7 | 유닛/import/.fei, 분리 컴파일, std 초안 | 다중 유닛 프로그램 빌드 | +| M8 | **제네릭** (모노모피제이션) | `List(T)`, `Map(K,V)`를 Ferro로 재작성 | +| M9 | bits16 타깃: far, `@seg_ptr`, 메모리 모델, asm, interrupt fn | DOSBox에서 VGA 데모 실행 | +| M10 | 컴파일러 B를 Ferro로 작성, A로 빌드 | B가 M1~M9 테스트 통과 | +| M11 | 셀프호스팅 fixpoint | B(B(B)) == B(B) 바이트 동일, A 폐기 | +| M12 | 386 네이티브 백엔드 | gcc 없이 빌드, 컴파일 속도 10배 | +| M13 | 8086 네이티브 백엔드 | Watcom 없이 bits16 빌드 | + +M8은 M9보다 앞이다(제네릭 없이 표준 라이브러리를 쓰는 기간을 최소화). + +--- + +## 12. 테스트 + +``` +tests/ + pass/*.fe + *.expected 컴파일→실행→stdout 비교 + fail/*.fe 첫 줄 "// ERROR::<메시지 일부>" + run16/*.fe bits16 빌드 후 DOSBox 실행, 출력 파일 비교 + boot/ A/B 출력 비교, fixpoint 검증 +``` + +- `fail/`은 규칙별 최소 3개: R1(이동 후 사용), R3(직접 drop 호출), R4(필드에 참조), R5(스코프 초과), R6(배타성 위반), R7(무효화), R8(참조 반환 바인딩), R9(unsafe 밖 raw 역참조), match 완전성, 암묵 변환, 타입 불일치. +- 각 마일스톤은 해당 기능의 pass/fail 테스트와 함께 완료한다. +- 회귀 실행: `make test` — 전 타깃 전 테스트. + +--- + +## 13. 의도적으로 제외한 기능 + +| 기능 | 제외 이유 | 대체 수단 | +|---|---|---| +| 라이프타임 표기 (`'a`) | 전역 분석 필요 | R4 (2급 참조) | +| 트레잇/인터페이스 | 복잡도 대비 이득 낮음 | 함수 포인터 struct: `struct Writer { ctx: *void, write: fn(*void, []u8) -> !usize }` | +| 클로저 | 캡처 = 참조 저장 = R4 위반 | 콜백에 `ctx: *void` 전달 | +| 매크로 / 전처리기 | 도구 지원과 컴파일 속도 파괴 | `const`, `comptime if`, 제네릭 | +| 예외 | 언와인딩 기반 시설 없음, 비용 큼 | 에러 유니온 | +| GC | 결정적 비용 원칙 위반 | 소유권 + RAII + 아레나 | +| 연산자 오버로딩 | 숨은 비용 | 메서드 | +| 가변 인자 | ABI 복잡, 타입 안전 불가 | 제네릭 `comptime` 파라미터 | +| 튜플 / 다중 반환 | 이름 없는 필드는 가독성 손해 | struct | +| 암묵 형변환 | 버그 원인 1위 | `as` | +| 스레드 | DOS에 없음 | — | +| 상속 | — | 합성 | + diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..faeb710 --- /dev/null +++ b/TODO.md @@ -0,0 +1,5 @@ +# TODO + +- [x] Add a non-reboot abort path for a hung DOS command: inject `Ctrl+C` through QEMU's monitor and wait for the serial agent to recover. +- [x] Apply a configurable timeout to `dos_exec` and invoke the non-reboot abort path on timeout. +- [x] Expose `dos_abort` for an immediate user-requested command interruption.