Files
secretsplash/Editor/Core/PCdrvSerialHost.cs
Jan Racek 4aa4e49424 psst
2026-03-24 13:00:54 +01:00

770 lines
28 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Ports;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace SplashEdit.EditorCode
{
/// <summary>
/// PCdrv Host — serves files to a PS1 over serial (Unirom/NOTPSXSerial protocol).
///
/// PCdrv uses MIPS `break` instructions to request file I/O from the host.
/// For real hardware running Unirom, we must:
/// 1. Enter debug mode (DEBG/OKAY) — installs Unirom's kernel-resident SIO handler
/// 2. Continue execution (CONT/OKAY)
/// 3. Monitor serial for escape sequences: 0x00 followed by 'p' = PCDrv command
/// 4. Handle file operations (init, open, read, close, seek, etc.)
///
/// Without entering debug mode first, `break` instructions cause an unhandled
/// "BP break (0x9)" crash because no handler is registered.
///
/// Protocol based on NOTPSXSerial: https://github.com/JonathanDotCel/NOTPSXSerial
/// </summary>
public class PCdrvSerialHost : IDisposable
{
private SerialPort _port;
private CancellationTokenSource _cts;
private Task _listenTask;
private readonly string _portName;
private readonly int _baudRate;
private readonly string _baseDir;
private readonly Action<string> _log;
private readonly Action<string> _psxLog;
// File handle table (1-indexed, handles are not recycled)
private readonly List<PCFile> _files = new List<PCFile>();
private class PCFile
{
public string Name;
public FileStream Stream;
public int Handle;
public bool Closed;
public FileAccess Mode;
}
// Protocol escape char — PCDrv commands are prefixed with 0x00 + 'p'
private const byte ESCAPE_CHAR = 0x00;
// PCDrv function codes (from Unirom kernel)
private const int FUNC_INIT = 0x101;
private const int FUNC_CREAT = 0x102;
private const int FUNC_OPEN = 0x103;
private const int FUNC_CLOSE = 0x104;
private const int FUNC_READ = 0x105;
private const int FUNC_WRITE = 0x106;
private const int FUNC_SEEK = 0x107;
public bool IsRunning => _listenTask != null && !_listenTask.IsCompleted;
public PCdrvSerialHost(string portName, int baudRate, string baseDir, Action<string> log, Action<string> psxLog = null)
{
_portName = portName;
_baudRate = baudRate;
_baseDir = baseDir;
_log = log;
_psxLog = psxLog;
}
/// <summary>
/// Opens a new serial port and begins the monitor/PCdrv loop.
/// Note: DEBG must have been sent BEFORE the exe was uploaded (via UniromUploader.UploadExeForPCdrv).
/// Use the Start(SerialPort) overload to pass an already-open port from the uploader.
/// </summary>
public void Start()
{
if (IsRunning) return;
_port = new SerialPort(_portName, _baudRate)
{
ReadTimeout = 5000,
WriteTimeout = 5000,
StopBits = StopBits.Two,
Parity = Parity.None,
DataBits = 8,
Handshake = Handshake.None,
DtrEnable = true,
RtsEnable = true
};
_port.Open();
_log?.Invoke($"PCdrv host: opened {_portName} @ {_baudRate}");
_log?.Invoke($"PCdrv host: serving files from {_baseDir}");
StartMonitorLoop();
}
/// <summary>
/// Starts the PCDrv monitor loop on an already-open serial port.
/// Use this after UniromUploader.UploadExeForPCdrv() which sends DEBG → SEXE
/// and returns the open port. The debug hooks are already installed and the
/// exe is already running — we just need to listen for escape sequences.
/// </summary>
public void Start(SerialPort openPort)
{
if (IsRunning) return;
_port = openPort;
_log?.Invoke($"PCdrv host: serving files from {_baseDir}");
_log?.Invoke("PCdrv host: monitoring for PCDrv requests...");
StartMonitorLoop();
}
private void StartMonitorLoop()
{
_cts = new CancellationTokenSource();
_listenTask = Task.Run(() => MonitorLoop(_cts.Token));
}
public void Stop()
{
_cts?.Cancel();
try { _listenTask?.Wait(2000); } catch { }
foreach (var f in _files)
{
if (!f.Closed && f.Stream != null)
{
try { f.Stream.Close(); f.Stream.Dispose(); } catch { }
}
}
_files.Clear();
if (_port != null && _port.IsOpen)
{
try { _port.Close(); } catch { }
}
_port?.Dispose();
_port = null;
_log?.Invoke("PCdrv host stopped.");
}
public void Dispose() => Stop();
// ═══════════════════════════════════════════════════════════════
// Monitor loop — reads serial byte-by-byte looking for escape sequences
// Matches NOTPSXSerial's Bridge.MonitorSerial()
// ═══════════════════════════════════════════════════════════════
private void MonitorLoop(CancellationToken ct)
{
bool lastByteWasEscape = false;
var textBuffer = new StringBuilder();
int totalBytesReceived = 0;
DateTime lastLogTime = DateTime.Now;
_log?.Invoke("PCdrv monitor: waiting for data from PS1...");
while (!ct.IsCancellationRequested)
{
try
{
if (_port.BytesToRead == 0)
{
// Flush any accumulated text output periodically
if (textBuffer.Length > 0 && (DateTime.Now - lastLogTime).TotalMilliseconds > 100)
{
EmitPsxLine(textBuffer.ToString());
textBuffer.Clear();
lastLogTime = DateTime.Now;
}
Thread.Sleep(1);
continue;
}
int b = _port.ReadByte();
totalBytesReceived++;
// Log first bytes received to help diagnose protocol issues
if (totalBytesReceived <= 32)
{
_log?.Invoke($"PCdrv monitor: byte #{totalBytesReceived} = 0x{b:X2} ('{(b >= 0x20 && b < 0x7F ? (char)b : '.')}')");
}
else if (totalBytesReceived == 33)
{
_log?.Invoke("PCdrv monitor: (suppressing per-byte logging, check PS1> lines for output)");
}
if (lastByteWasEscape)
{
lastByteWasEscape = false;
// Flush any text before handling escape
if (textBuffer.Length > 0)
{
EmitPsxLine(textBuffer.ToString());
textBuffer.Clear();
}
if (b == ESCAPE_CHAR)
{
// Double escape = literal 0x00 in output, ignore
continue;
}
if (b == 'p')
{
// PCDrv command incoming
_log?.Invoke("PCdrv monitor: got escape+p → PCDrv command!");
HandlePCDrvCommand(ct);
}
else
{
// Unknown escape sequence — log it
_log?.Invoke($"PCdrv monitor: unknown escape seq: 0x00 + 0x{b:X2} ('{(b >= 0x20 && b < 0x7F ? (char)b : '.')}')");
}
continue;
}
if (b == ESCAPE_CHAR)
{
lastByteWasEscape = true;
continue;
}
// Regular byte — this is printf output from the PS1
if (b == '\n' || b == '\r')
{
if (textBuffer.Length > 0)
{
EmitPsxLine(textBuffer.ToString());
textBuffer.Clear();
lastLogTime = DateTime.Now;
}
}
else if (b >= 0x20 && b < 0x7F)
{
textBuffer.Append((char)b);
// Flush long lines immediately
if (textBuffer.Length >= 200)
{
EmitPsxLine(textBuffer.ToString());
textBuffer.Clear();
lastLogTime = DateTime.Now;
}
}
// else: non-printable byte that's not escape, skip
}
catch (TimeoutException) { }
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
if (!ct.IsCancellationRequested)
_log?.Invoke($"PCdrv monitor error: {ex.Message}");
}
}
}
// ═══════════════════════════════════════════════════════════════
// PCDrv command dispatcher
// Matches NOTPSXSerial's PCDrv.ReadCommand()
// ═══════════════════════════════════════════════════════════════
private void HandlePCDrvCommand(CancellationToken ct)
{
int funcCode = ReadInt32(ct);
switch (funcCode)
{
case FUNC_INIT: HandleInit(); break;
case FUNC_CREAT: HandleCreate(ct); break;
case FUNC_OPEN: HandleOpen(ct); break;
case FUNC_CLOSE: HandleClose(ct); break;
case FUNC_READ: HandleRead(ct); break;
case FUNC_WRITE: HandleWrite(ct); break;
case FUNC_SEEK: HandleSeek(ct); break;
default:
_log?.Invoke($"PCdrv: unknown function 0x{funcCode:X}");
break;
}
}
// ═══════════════════════════════════════════════════════════════
// Individual PCDrv handlers — match NOTPSXSerial's PCDrv.cs
// ═══════════════════════════════════════════════════════════════
private void HandleInit()
{
_log?.Invoke("PCdrv: INIT");
SendString("OKAY");
_port.Write(new byte[] { 0 }, 0, 1); // null terminator expected by Unirom
}
private void HandleOpen(CancellationToken ct)
{
// Unirom sends: we respond OKAY first, then read filename + mode
SendString("OKAY");
string filename = ReadNullTermString(ct);
int modeParam = ReadInt32(ct);
// Log raw bytes for debugging garbled filenames
_log?.Invoke($"PCdrv: OPEN \"{filename}\" mode={modeParam} (len={filename.Length}, hex={BitConverter.ToString(System.Text.Encoding.ASCII.GetBytes(filename))})");
// Check if already open
var existing = FindOpenFile(filename);
if (existing != null)
{
_log?.Invoke($"PCdrv: already open, handle={existing.Handle}");
SendString("OKAY");
WriteInt32(existing.Handle);
return;
}
string fullPath;
try
{
fullPath = ResolvePath(filename);
}
catch (Exception ex)
{
_log?.Invoke($"PCdrv: invalid filename \"{filename}\": {ex.Message}");
SendString("NOPE");
return;
}
if (!File.Exists(fullPath))
{
_log?.Invoke($"PCdrv: file not found: {fullPath}");
SendString("NOPE");
return;
}
try
{
var fs = new FileStream(fullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
int handle = NextHandle();
_files.Add(new PCFile { Name = filename, Stream = fs, Handle = handle, Closed = false, Mode = FileAccess.ReadWrite });
SendString("OKAY");
WriteInt32(handle);
_log?.Invoke($"PCdrv: opened handle={handle}");
}
catch (Exception ex)
{
_log?.Invoke($"PCdrv: open failed: {ex.Message}");
SendString("NOPE");
}
}
private void HandleCreate(CancellationToken ct)
{
SendString("OKAY");
string filename = ReadNullTermString(ct);
int parameters = ReadInt32(ct);
_log?.Invoke($"PCdrv: CREAT \"{filename}\" params={parameters}");
var existing = FindOpenFile(filename);
if (existing != null)
{
SendString("OKAY");
WriteInt32(existing.Handle);
return;
}
string fullPath;
try { fullPath = ResolvePath(filename); }
catch (Exception ex)
{
_log?.Invoke($"PCdrv: invalid filename \"{filename}\": {ex.Message}");
SendString("NOPE");
return;
}
try
{
// Create or truncate the file
if (!File.Exists(fullPath))
{
var temp = File.Create(fullPath);
temp.Flush(); temp.Close(); temp.Dispose();
}
var fs = new FileStream(fullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
int handle = NextHandle();
_files.Add(new PCFile { Name = filename, Stream = fs, Handle = handle, Closed = false, Mode = FileAccess.ReadWrite });
SendString("OKAY");
WriteInt32(handle);
_log?.Invoke($"PCdrv: created handle={handle}");
}
catch (Exception ex)
{
_log?.Invoke($"PCdrv: create failed: {ex.Message}");
SendString("NOPE");
}
}
private void HandleClose(CancellationToken ct)
{
// Unirom sends: we respond OKAY first, then read handle + 2 unused params
SendString("OKAY");
int handle = ReadInt32(ct);
int _unused1 = ReadInt32(ct);
int _unused2 = ReadInt32(ct);
_log?.Invoke($"PCdrv: CLOSE handle={handle}");
var f = FindOpenFile(handle);
if (f == null)
{
// No such file — "great success" per NOTPSXSerial
SendString("OKAY");
WriteInt32(0);
return;
}
try
{
f.Stream.Close();
f.Stream.Dispose();
f.Closed = true;
SendString("OKAY");
WriteInt32(handle);
_log?.Invoke($"PCdrv: closed handle={handle}");
}
catch (Exception ex)
{
_log?.Invoke($"PCdrv: close error: {ex.Message}");
SendString("NOPE");
}
}
private void HandleRead(CancellationToken ct)
{
// Unirom sends: we respond OKAY first, then read handle + len + memaddr
SendString("OKAY");
int handle = ReadInt32(ct);
int length = ReadInt32(ct);
int memAddr = ReadInt32(ct); // for debugging only
_log?.Invoke($"PCdrv: READ handle={handle} len=0x{length:X} memAddr=0x{memAddr:X}");
var f = FindOpenFile(handle);
if (f == null)
{
_log?.Invoke($"PCdrv: no file with handle {handle}");
SendString("NOPE");
return;
}
try
{
byte[] data = new byte[length];
int bytesRead = f.Stream.Read(data, 0, length);
SendString("OKAY");
WriteInt32(data.Length);
// Checksum (simple byte sum, forced V3 = true per NOTPSXSerial)
uint checksum = CalculateChecksum(data);
WriteUInt32(checksum);
// Send data using chunked writer (with per-chunk ack for V2+)
WriteDataChunked(data);
_log?.Invoke($"PCdrv: sent {bytesRead} bytes for handle={handle}");
}
catch (Exception ex)
{
_log?.Invoke($"PCdrv: read error: {ex.Message}");
SendString("NOPE");
}
}
private void HandleWrite(CancellationToken ct)
{
SendString("OKAY");
int handle = ReadInt32(ct);
int length = ReadInt32(ct);
int memAddr = ReadInt32(ct);
_log?.Invoke($"PCdrv: WRITE handle={handle} len={length}");
var f = FindOpenFile(handle);
if (f == null)
{
SendString("NOPE");
return;
}
SendString("OKAY");
// Read data from PSX
byte[] data = ReadBytes(length, ct);
f.Stream.Write(data, 0, length);
f.Stream.Flush();
SendString("OKAY");
WriteInt32(length);
_log?.Invoke($"PCdrv: wrote {length} bytes to handle={handle}");
}
private void HandleSeek(CancellationToken ct)
{
SendString("OKAY");
int handle = ReadInt32(ct);
int offset = ReadInt32(ct);
int whence = ReadInt32(ct);
_log?.Invoke($"PCdrv: SEEK handle={handle} offset={offset} whence={whence}");
var f = FindOpenFile(handle);
if (f == null)
{
SendString("NOPE");
return;
}
SeekOrigin origin = whence switch
{
0 => SeekOrigin.Begin,
1 => SeekOrigin.Current,
2 => SeekOrigin.End,
_ => SeekOrigin.Begin
};
try
{
long newPos = f.Stream.Seek(offset, origin);
SendString("OKAY");
WriteInt32((int)newPos);
}
catch (Exception ex)
{
_log?.Invoke($"PCdrv: seek error: {ex.Message}");
SendString("NOPE");
}
}
// ═══════════════════════════════════════════════════════════════
// PS1 output routing
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// Routes PS1 printf output to PSXConsoleWindow (via _psxLog) if available,
/// otherwise falls back to the control panel log.
/// </summary>
private void EmitPsxLine(string text)
{
if (_psxLog != null)
_psxLog.Invoke(text);
else
_log?.Invoke($"PS1> {text}");
}
// ═══════════════════════════════════════════════════════════════
// Chunked data write — matches NOTPSXSerial's WriteBytes()
// Sends data in 2048-byte chunks; for protocol V2+ Unirom
// responds with CHEK/MORE/ERR! per chunk.
// ═══════════════════════════════════════════════════════════════
private void WriteDataChunked(byte[] data)
{
int chunkSize = 2048;
for (int i = 0; i < data.Length; i += chunkSize)
{
int thisChunk = Math.Min(chunkSize, data.Length - i);
_port.Write(data, i, thisChunk);
// Wait for bytes to drain
while (_port.BytesToWrite > 0)
Thread.Sleep(0);
// V2 protocol: wait for CHEK, send chunk checksum, wait for MORE
// For now, handle this if present
if (_port.BytesToRead >= 4)
{
string resp = ReadFixedString(4);
if (resp == "CHEK")
{
ulong chunkSum = 0;
for (int j = 0; j < thisChunk; j++)
chunkSum += data[i + j];
_port.Write(BitConverter.GetBytes((uint)chunkSum), 0, 4);
Thread.Sleep(1);
// Wait for MORE or ERR!
string ack = WaitFor4CharResponse(5000);
if (ack == "ERR!")
{
_log?.Invoke("PCdrv: chunk checksum error, retrying...");
i -= chunkSize; // retry
}
}
}
}
}
// ═══════════════════════════════════════════════════════════════
// File handle helpers
// ═══════════════════════════════════════════════════════════════
private int NextHandle() => _files.Count + 1;
private PCFile FindOpenFile(string name)
{
for (int i = 0; i < _files.Count; i++)
{
if (!_files[i].Closed && _files[i].Name.Equals(name, StringComparison.OrdinalIgnoreCase))
return _files[i];
}
return null;
}
private PCFile FindOpenFile(int handle)
{
for (int i = 0; i < _files.Count; i++)
{
if (!_files[i].Closed && _files[i].Handle == handle)
return _files[i];
}
return null;
}
private string ResolvePath(string filename)
{
// Strip leading slashes and backslashes
filename = filename.TrimStart('/', '\\');
return Path.Combine(_baseDir, filename);
}
// ═══════════════════════════════════════════════════════════════
// Low-level serial I/O
// ═══════════════════════════════════════════════════════════════
private int ReadInt32(CancellationToken ct)
{
byte[] buf = new byte[4];
for (int i = 0; i < 4; i++)
buf[i] = (byte)ReadByteBlocking(ct);
return BitConverter.ToInt32(buf, 0);
}
private uint ReadUInt32(CancellationToken ct)
{
byte[] buf = new byte[4];
for (int i = 0; i < 4; i++)
buf[i] = (byte)ReadByteBlocking(ct);
return BitConverter.ToUInt32(buf, 0);
}
private byte[] ReadBytes(int count, CancellationToken ct)
{
byte[] data = new byte[count];
int pos = 0;
while (pos < count)
{
ct.ThrowIfCancellationRequested();
if (_port.BytesToRead > 0)
{
int read = _port.Read(data, pos, Math.Min(count - pos, _port.BytesToRead));
pos += read;
}
else
{
Thread.Sleep(1);
}
}
return data;
}
private int ReadByteBlocking(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
if (_port.BytesToRead > 0)
return _port.ReadByte();
Thread.Sleep(1);
}
throw new OperationCanceledException();
}
private string ReadNullTermString(CancellationToken ct)
{
var sb = new StringBuilder();
while (true)
{
int b = ReadByteBlocking(ct);
if (b == 0) break;
sb.Append((char)b);
if (sb.Length > 255) break;
}
return sb.ToString();
}
private void SendString(string s)
{
byte[] data = Encoding.ASCII.GetBytes(s);
_port.Write(data, 0, data.Length);
}
private void WriteInt32(int value)
{
_port.Write(BitConverter.GetBytes(value), 0, 4);
}
private void WriteUInt32(uint value)
{
_port.Write(BitConverter.GetBytes(value), 0, 4);
}
private string ReadFixedString(int count)
{
var sb = new StringBuilder();
for (int i = 0; i < count; i++)
{
if (_port.BytesToRead > 0)
sb.Append((char)_port.ReadByte());
}
return sb.ToString();
}
private string WaitFor4CharResponse(int timeoutMs)
{
string buffer = "";
DateTime deadline = DateTime.Now.AddMilliseconds(timeoutMs);
while (DateTime.Now < deadline)
{
if (_port.BytesToRead > 0)
{
buffer += (char)_port.ReadByte();
if (buffer.Length > 4)
buffer = buffer.Substring(buffer.Length - 4);
if (buffer == "MORE" || buffer == "ERR!")
return buffer;
}
else
{
Thread.Sleep(1);
}
}
return buffer;
}
private static uint CalculateChecksum(byte[] data)
{
// Force V3-style for PCDrv reads (per NOTPSXSerial: forceProtocolV3=true)
uint sum = 0;
for (int i = 0; i < data.Length; i++)
sum += data[i];
return sum;
}
}
}