diff --git a/Amf/Amf0.cs b/Amf/Amf0.cs new file mode 100644 index 0000000..f0ccb69 --- /dev/null +++ b/Amf/Amf0.cs @@ -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 (writer also accepts POCOs / anonymous objects) +// null (0x05), undefined (0x06) -> null +// reference(0x07) -> the previously read object/array +// ecma arr (0x08) -> Dictionary +// strict (0x0A) <-> List (writer accepts any IEnumerable that isn't a string) +// date (0x0B) <-> DateTime (UTC) +// xml (0x0F) -> string +// typed obj(0x10) -> Dictionary 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 Headers { get; } = new(); + public List 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 _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(); _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(); _refs.Add(o); ReadProps(o); return o; + } + case 0x0A: // strict array + { + uint n = U32(); + var list = new List((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 { ["__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 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 b = stackalloc byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); _s.Write(b); } + public void U32(uint v) { Span b = stackalloc byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v); _s.Write(b); } + void F64(double v) { Span 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().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); } +} diff --git a/Amf/FlashGateway.cs b/Amf/FlashGateway.cs new file mode 100644 index 0000000..b9f8244 --- /dev/null +++ b/Amf/FlashGateway.cs @@ -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, 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(), // stored, never read by the SWF + ruleTabs = new[] + { + new { id = "classic", index = 0 }, + new { id = "rally", index = 1 }, + new { id = "advanced", index = 2 }, + }, // [{ id = , 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 ?? new List(); + 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 a, int i) => i < a.Count ? a[i]?.ToString() ?? "" : ""; +} diff --git a/Controllers/CDLController.cs b/Controllers/CDLController.cs new file mode 100644 index 0000000..ce480ae --- /dev/null +++ b/Controllers/CDLController.cs @@ -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($"\n{Guid.NewGuid()}", "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 + } + } +} diff --git a/Program.cs b/Program.cs index 9035406..7c960c7 100644 --- a/Program.cs +++ b/Program.cs @@ -45,6 +45,7 @@ namespace NotPogo app.UseAuthorization(); app.MapControllers(); + app.MapFlashGateway(); app.Run(); } diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json index e74d67c..c01be2b 100644 --- a/Properties/launchSettings.json +++ b/Properties/launchSettings.json @@ -6,7 +6,7 @@ "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": true, - "applicationUrl": "http://localhost:8080" + "applicationUrl": "" }, "https": { "commandName": "Project", diff --git a/appsettings.json b/appsettings.json index df518f5..7a50945 100644 --- a/appsettings.json +++ b/appsettings.json @@ -6,5 +6,15 @@ } }, "AllowedHosts": "*", - "GameServerPort": 11200 + "GameServerPort": 11200, + "Kestrel": { + "Endpoints": { + "Http8080Local": { + "Url": "http://localhost:8080" + }, + "Http80": { + "Url": "http://*:80" + } + } + } }