Compare commits

..

2 Commits

Author SHA1 Message Date
f6e304929f Implement Stubbed CDL Endpoints
Listen On All Addreses Port 80 Alongside 8080 (remove if you cannot launch the emulator)
2026-10-05 13:20:50 -07:00
6a97c6fe10 Ignore Dev Appsettings 2026-10-05 13:19:57 -07:00
8 changed files with 370 additions and 13 deletions

3
.gitignore vendored
View File

@ -363,4 +363,5 @@ MigrationBackup/
FodyWeavers.xsd
# Game Assets (copyrighted stuff lol)
StaticFiles/
StaticFiles/
/appsettings.Development.json

221
Amf/Amf0.cs Normal file
View File

@ -0,0 +1,221 @@
// Minimal AMF0 codec + Flash Remoting packet format (what mx.remoting / NetServices speaks).
// Reference: Adobe "AMF0 File Format Specification" + the AMF remoting packet envelope.
//
// .NET mapping:
// number (0x00) <-> double (any .NET numeric type is written as a double)
// boolean (0x01) <-> bool
// string (0x02 / 0x0C long) <-> string
// object (0x03) <-> Dictionary<string, object?> (writer also accepts POCOs / anonymous objects)
// null (0x05), undefined (0x06) -> null
// reference(0x07) -> the previously read object/array
// ecma arr (0x08) -> Dictionary<string, object?>
// strict (0x0A) <-> List<object?> (writer accepts any IEnumerable that isn't a string)
// date (0x0B) <-> DateTime (UTC)
// xml (0x0F) -> string
// typed obj(0x10) -> Dictionary<string, object?> with the class name under "__className"
using System.Buffers.Binary;
using System.Collections;
using System.Reflection;
using System.Text;
namespace NotPogo.Amf;
public sealed record AmfHeader(string Name, bool MustUnderstand, object? Value);
public sealed record AmfMessage(string Target, string Response, object? Body);
public sealed class AmfPacket
{
public ushort Version { get; set; } // Flash 8 / AS2 remoting sends 0
public List<AmfHeader> Headers { get; } = new();
public List<AmfMessage> Messages { get; } = new();
public static AmfPacket Read(byte[] data)
{
var r = new Amf0Reader(data);
var p = new AmfPacket { Version = r.U16() };
int headerCount = r.U16();
for (int i = 0; i < headerCount; i++)
{
string name = r.Utf8(r.U16());
bool must = r.U8() != 0;
r.U32(); // byte length (may be 0xFFFFFFFF = unknown); not needed
r.ResetReferences(); // each header/body has its own reference table
p.Headers.Add(new AmfHeader(name, must, r.ReadValue()));
}
int messageCount = r.U16();
for (int i = 0; i < messageCount; i++)
{
string target = r.Utf8(r.U16()); // e.g. "com.pogo.flash.FlashRoomsFeed.generateFeed"
string response = r.Utf8(r.U16()); // e.g. "/1"
r.U32();
r.ResetReferences();
p.Messages.Add(new AmfMessage(target, response, r.ReadValue()));
}
return p;
}
public byte[] Write()
{
var w = new Amf0Writer();
w.U16(Version);
w.U16((ushort)Headers.Count);
foreach (var h in Headers)
{
w.ShortString(h.Name);
w.U8((byte)(h.MustUnderstand ? 1 : 0));
WriteSized(w, h.Value);
}
w.U16((ushort)Messages.Count);
foreach (var m in Messages)
{
w.ShortString(m.Target); // e.g. "/1/onResult"
w.ShortString(m.Response); // "null" for replies
WriteSized(w, m.Body);
}
return w.ToArray();
}
// Values are prefixed with their byte length, so encode separately first.
static void WriteSized(Amf0Writer w, object? value)
{
var inner = new Amf0Writer();
inner.WriteValue(value);
var bytes = inner.ToArray();
w.U32((uint)bytes.Length);
w.Raw(bytes);
}
}
public sealed class Amf0Reader
{
readonly byte[] _d;
int _p;
readonly List<object> _refs = new();
public Amf0Reader(byte[] data) => _d = data;
public void ResetReferences() => _refs.Clear();
public byte U8() => _d[_p++];
public ushort U16() { var v = BinaryPrimitives.ReadUInt16BigEndian(_d.AsSpan(_p)); _p += 2; return v; }
public uint U32() { var v = BinaryPrimitives.ReadUInt32BigEndian(_d.AsSpan(_p)); _p += 4; return v; }
double F64() { var v = BinaryPrimitives.ReadDoubleBigEndian(_d.AsSpan(_p)); _p += 8; return v; }
public string Utf8(int len) { var s = Encoding.UTF8.GetString(_d, _p, len); _p += len; return s; }
public object? ReadValue()
{
byte marker = U8();
switch (marker)
{
case 0x00: return F64();
case 0x01: return U8() != 0;
case 0x02: return Utf8(U16());
case 0x03: { var o = new Dictionary<string, object?>(); _refs.Add(o); ReadProps(o); return o; }
case 0x05: return null; // null
case 0x06: return null; // undefined
case 0x07: return _refs[U16()]; // reference
case 0x08: // ECMA (associative) array
{
U32(); // approximate count; ignore
var o = new Dictionary<string, object?>(); _refs.Add(o); ReadProps(o); return o;
}
case 0x0A: // strict array
{
uint n = U32();
var list = new List<object?>((int)Math.Min(n, 4096)); _refs.Add(list);
for (uint i = 0; i < n; i++) list.Add(ReadValue());
return list;
}
case 0x0B: // date: ms since epoch + tz (ignored)
{
double ms = F64(); U16();
return DateTime.UnixEpoch.AddMilliseconds(ms);
}
case 0x0C: return Utf8((int)U32()); // long string
case 0x0F: return Utf8((int)U32()); // XML document (as text)
case 0x10: // typed object
{
string cls = Utf8(U16());
var o = new Dictionary<string, object?> { ["__className"] = cls }; _refs.Add(o); ReadProps(o); return o;
}
case 0x11: throw new NotSupportedException("AMF3 value inside AMF0 (Flash 9+ only; AS2 never sends this)");
default: throw new FormatException($"Unknown AMF0 marker 0x{marker:X2} at offset {_p - 1}");
}
}
// key/value pairs until an empty key followed by the object-end marker 0x09
void ReadProps(Dictionary<string, object?> o)
{
while (true)
{
string key = Utf8(U16());
if (key.Length == 0 && _d[_p] == 0x09) { _p++; return; }
o[key] = ReadValue();
}
}
}
public sealed class Amf0Writer
{
readonly MemoryStream _s = new();
public byte[] ToArray() => _s.ToArray();
public void Raw(byte[] b) => _s.Write(b);
public void U8(byte v) => _s.WriteByte(v);
public void U16(ushort v) { Span<byte> b = stackalloc byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); _s.Write(b); }
public void U32(uint v) { Span<byte> b = stackalloc byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v); _s.Write(b); }
void F64(double v) { Span<byte> b = stackalloc byte[8]; BinaryPrimitives.WriteDoubleBigEndian(b, v); _s.Write(b); }
public void ShortString(string s)
{
var b = Encoding.UTF8.GetBytes(s);
if (b.Length > ushort.MaxValue) throw new ArgumentException("string too long for a short string");
U16((ushort)b.Length); _s.Write(b);
}
public void WriteValue(object? v)
{
switch (v)
{
case null: U8(0x05); return;
case bool b: U8(0x01); U8((byte)(b ? 1 : 0)); return;
case string s:
{
var bytes = Encoding.UTF8.GetBytes(s);
if (bytes.Length <= ushort.MaxValue) { U8(0x02); U16((ushort)bytes.Length); }
else { U8(0x0C); U32((uint)bytes.Length); }
_s.Write(bytes); return;
}
case byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal:
U8(0x00); F64(Convert.ToDouble(v)); return;
case Enum e: U8(0x00); F64(Convert.ToDouble(e)); return;
case DateTime dt:
U8(0x0B); F64((dt.ToUniversalTime() - DateTime.UnixEpoch).TotalMilliseconds); U16(0); return;
case IDictionary dict:
U8(0x03);
foreach (DictionaryEntry kv in dict) { ShortString(kv.Key.ToString()!); WriteValue(kv.Value); }
EndObject(); return;
case IEnumerable list:
{
var items = list.Cast<object?>().ToList();
U8(0x0A); U32((uint)items.Count);
foreach (var item in items) WriteValue(item);
return;
}
default: // POCO / anonymous object -> anonymous AMF object
U8(0x03);
foreach (var prop in v.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (prop.GetIndexParameters().Length != 0) continue;
ShortString(prop.Name); WriteValue(prop.GetValue(v));
}
EndObject(); return;
}
}
void EndObject() { U16(0); U8(0x09); }
}

98
Amf/FlashGateway.cs Normal file
View File

@ -0,0 +1,98 @@
// /flashservices/gateway endpoint for roomsel.swf (com.pogo.roomsel.RoomTabs).
// Register in Program.cs: app.MapFlashGateway();
namespace NotPogo.Amf;
public static class FlashGateway
{
// "Service.method" -> handler(args). Args arrive as AMF0 values: strings, doubles, etc.
static readonly Dictionary<string, Func<List<object?>, object?>> Handlers = new()
{
// RoomTabs.getRoomFeed -> generateFeed(gameCode, siteCode)
["com.pogo.flash.FlashRoomsFeed.generateFeed"] = args =>
{
string gameCode = Str(args, 0), siteCode = Str(args, 1);
return new
{
errorMsg = (string?)null, // non-null = SWF shows the error
tabs = new[] { new { tabId = 1, index = 0 } },
rooms = new[]
{
// shown only if tabId == selected tab AND ruleId == gRuleId (omit ruleId if noruleid)
// clickable only if numPeople < softCapacity && live
new { key = "wr_classic_1", humanName = "NotPogo WR Classic", numPeople = 0, softCapacity = 50, live = true, tabId = 1, ruleId = "classic" },
new { key = "wr_rally_1", humanName = "NotPogo WR Rally", numPeople = 0, softCapacity = 50, live = true, tabId = 1, ruleId = "rally" },
new { key = "wr_advanced_1", humanName = "NotPogo WR Expert", numPeople = 0, softCapacity = 50, live = true, tabId = 1, ruleId = "advanced" },
},
rules = Array.Empty<object>(), // stored, never read by the SWF
ruleTabs = new[]
{
new { id = "classic", index = 0 },
new { id = "rally", index = 1 },
new { id = "advanced", index = 2 },
}, // [{ id = <ruleId>, index = n }] -> ruleIdx for Play Now
};
},
// RoomTabs.goRoom -> connectRoom(roomKey, gameCode, siteCode, lkey)
["com.pogo.flash.FlashRoomConnect.connectRoom"] = args =>
RoomInfo(Str(args, 0)),
// RoomTabs.playNow -> playNow(gameCode, siteCode, lkey, tabIdx, ruleIdx)
["com.pogo.flash.FlashRoomConnect.playNow"] = args =>
RoomInfo("wr_room_1"), // pick a room server-side
};
// roomInfo becomes FSCommand goRoom "humanName '..' key '..' host '..' port '..'"; the exe connects TCP to host:port
static object RoomInfo(string roomKey) => new
{
errorMsg = (string?)null, // null or "NO ERROR" = success
roomInfo = new { humanName = "NotPogo Room", key = roomKey, host = "downld.pogo.com", port = 11200 },
};
public static void MapFlashGateway(this IEndpointRouteBuilder app)
{
app.MapPost("/flashservices/gateway", async (HttpContext ctx, ILoggerFactory lf) =>
{
var log = lf.CreateLogger("FlashGateway");
using var ms = new MemoryStream();
await ctx.Request.Body.CopyToAsync(ms);
var request = AmfPacket.Read(ms.ToArray());
var reply = new AmfPacket { Version = request.Version };
// Flash may batch several calls into one POST; answer each, echoing its response URI.
foreach (var msg in request.Messages)
{
var args = msg.Body as List<object?> ?? new List<object?>();
log.LogInformation("AMF call {Target}({Args}) -> {Response}", msg.Target, string.Join(", ", args), msg.Response);
try
{
if (!Handlers.TryGetValue(msg.Target, out var handler))
throw new InvalidOperationException($"No such service method: {msg.Target}");
reply.Messages.Add(new AmfMessage(msg.Response + "/onResult", "null", handler(args)));
}
catch (Exception ex)
{
log.LogWarning(ex, "AMF call {Target} failed", msg.Target);
// mx.remoting turns this into a Fault; "description" becomes faultstring
reply.Messages.Add(new AmfMessage(msg.Response + "/onStatus", "null", new
{
level = "error",
code = "SERVER.PROCESSING",
description = ex.Message,
details = "",
type = ex.GetType().Name,
}));
}
}
var bytes = reply.Write();
ctx.Response.ContentType = "application/x-amf";
ctx.Response.ContentLength = bytes.Length;
await ctx.Response.Body.WriteAsync(bytes);
});
}
static string Str(List<object?> a, int i) => i < a.Count ? a[i]?.ToString() ?? "" : "";
}

View File

@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Mvc;
namespace NotPogo.Controllers
{
public class CDLController : Controller
{
[HttpGet]
[Route("/cdl/cdl-info.jsp")]
public IResult CDLInfo(string lkey)
{
// this is just to satisfy download clients
if (string.IsNullOrEmpty(lkey))
return Results.Text("");
else
return Results.Text("error none screenname 'NotPogoUser' site 'pogo' tokens 9999 enti 'oava 1 obad 1'");
}
[HttpGet("/cdl/1/login")]
public IResult CDLLogin()
{
// more satisfying
return Results.Text($"<LoginResponse error=\"none\">\n<LoginSessionKey>{Guid.NewGuid()}</LoginSessionKey></LoginResponse>", "text/xml");
}
[HttpGet("/cdl/1/cdl-clientprops")]
public IActionResult CDLClientProps(string game)
{
string pathToProps = Path.Combine(AppContext.BaseDirectory, "StaticFiles", "Configs", "CDL", $"{game}.properties");
if (System.IO.File.Exists(pathToProps))
return Ok(System.IO.File.ReadAllText(pathToProps));
else
return Ok(); // don't send anything since we don't have the config for it on the server yet
}
}
}

View File

@ -45,6 +45,7 @@ namespace NotPogo
app.UseAuthorization();
app.MapControllers();
app.MapFlashGateway();
app.Run();
}

View File

@ -6,7 +6,7 @@
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:8080"
"applicationUrl": ""
},
"https": {
"commandName": "Project",

View File

@ -1,10 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"GameServerPort": 11200
}

View File

@ -6,5 +6,15 @@
}
},
"AllowedHosts": "*",
"GameServerPort": 11200
"GameServerPort": 11200,
"Kestrel": {
"Endpoints": {
"Http8080Local": {
"Url": "http://localhost:8080"
},
"Http80": {
"Url": "http://*:80"
}
}
}
}