summaryrefslogtreecommitdiff
path: root/src/main/smp/sys
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/smp/sys')
-rw-r--r--src/main/smp/sys/audio.smp29
-rw-r--r--src/main/smp/sys/draw.smp18
-rw-r--r--src/main/smp/sys/draw/Button.smp41
-rw-r--r--src/main/smp/sys/draw/Canvas.smp176
-rw-r--r--src/main/smp/sys/gfx.smp42
-rw-r--r--src/main/smp/sys/text.smp5
-rw-r--r--src/main/smp/sys/time.smp191
-rw-r--r--src/main/smp/sys/time/DateTime.smp11
-rw-r--r--src/main/smp/sys/time/Timers.smp55
9 files changed, 568 insertions, 0 deletions
diff --git a/src/main/smp/sys/audio.smp b/src/main/smp/sys/audio.smp
new file mode 100644
index 00000000..89e6dfa9
--- /dev/null
+++ b/src/main/smp/sys/audio.smp
@@ -0,0 +1,29 @@
+// Optional background audio. All API calls belong on the game/main thread.
+int OK = 0;
+int ERROR = -1;
+int BOOP = 1;
+int WIN = 2;
+int LOSE = 3;
+int RATE = 48000;
+{ i32 } simple_audio_open = "C";
+{ i64 u32 -> i32 } simple_audio_load = "C";
+{ i32 -> i32 } simple_audio_play = "C";
+{ i32 } simple_audio_stop = "C";
+{ i32 } simple_audio_playing = "C";
+{ i32 } simple_audio_close = "C";
+// Missing/disabled backend returns ERROR quietly; the game may remain silent.
+val open = { -> simple_audio_open(); };
+// Mono signed 16-bit PCM at RATE, up to ten seconds. Copies into native memory.
+// Load at startup: up to 28 custom handles, valid until close, then reload.
+val load = { i16[~] samples ->
+ i64 ptr = samples;
+ simple_audio_load(ptr,samples#);
+};
+// Starts/restarts immediately, replacing the previous sound; never waits for it.
+val play = { int sound -> simple_audio_play(sound); };
+val boop = { -> simple_audio_play(BOOP); };
+val win = { -> simple_audio_play(WIN); };
+val lose = { -> simple_audio_play(LOSE); };
+val stop = { -> simple_audio_stop(); };
+val playing = { -> simple_audio_playing(); };
+val close = { -> simple_audio_close(); };
diff --git a/src/main/smp/sys/draw.smp b/src/main/smp/sys/draw.smp
new file mode 100644
index 00000000..0e4a27d4
--- /dev/null
+++ b/src/main/smp/sys/draw.smp
@@ -0,0 +1,18 @@
+// Software raster drawing. No window or native calls; pixels are packed RGB (0x00RRGGBB).
+u32 BLACK = 0x000000;
+u32 WHITE = 0xffffff;
+u32 RED = 0xe05252;
+u32 GREEN = 0x48b88c;
+u32 BLUE = 0x558ee6;
+
+// Components are clamped to bytes rather than wrapping.
+val rgb = { int r, int g, int b ->
+ r = r<0 ? 0 : r>255 ? 255 : r;
+ g = g<0 ? 0 : g>255 ? 255 : g;
+ b = b<0 ? 0 : b>255 ? 255 : b;
+ u32 color = (r<<16) | (g<<8) | b;
+ return color;
+};
+
+val Canvas = Canvas; // Pixel canvas and drawing methods
+val Button = Button; // Pointer state and rendering for a rectangular button
diff --git a/src/main/smp/sys/draw/Button.smp b/src/main/smp/sys/draw/Button.smp
new file mode 100644
index 00000000..edf01119
--- /dev/null
+++ b/src/main/smp/sys/draw/Button.smp
@@ -0,0 +1,41 @@
+// Feed pointer state once per event-loop iteration. Activate on release inside,
+// only after a press that began inside. No native/window dependency.
+struct Button {
+ int x, y, width, height;
+ u8[~] !label;
+ bool !enabled=true, !hovered=false, !pressed=false;
+ bool !_held=false, !_armed=false;
+ new Button = { int px, int py, int w, int h, u8[~] text ->
+ x=px; y=py; width=w; height=h; label=text;
+ };
+ val contains = { i64 px, i64 py ->
+ if( width<=0 || height<=0 ) return false;
+ i64 right=x, bottom=y; right+=width; bottom+=height;
+ if( px<x || py<y || px>=right || py>=bottom ) return false;
+ return true;
+ };
+ val update = { i64 px, i64 py, bool down ->
+ hovered=false;
+ if( enabled ) hovered=contains(px,py);
+ if( !enabled ) _armed=false;
+ if( down && !_held ) _armed=hovered;
+ var clicked=false;
+ if( !down ) if( _held ) if( _armed ) if( hovered ) clicked=true;
+ if( !down ) _armed=false;
+ _held=down;
+ pressed=false;
+ if( down ) if( _armed ) if( hovered ) pressed=true;
+ return clicked;
+ };
+ val draw = { !sys.draw.Canvas screen, int scale, u32 foreground, u32 background, u32 active ->
+ screen.fillRect(x,y,width,height,enabled && (hovered || pressed) ? active : background);
+ screen.rect(x,y,width,height,2,enabled ? foreground : active);
+ if( scale>0 ) {
+ i64 textWidth=label#; textWidth=(textWidth*4-1)*scale;
+ if( textWidth<=width-8 && 5*scale<=height-8 )
+ screen.text(x+(width-textWidth)/2,y+(height-5*scale)/2+(pressed ? 1 : 0),
+ label,scale,enabled ? foreground : active);
+ }
+ return self;
+ };
+};
diff --git a/src/main/smp/sys/draw/Canvas.smp b/src/main/smp/sys/draw/Canvas.smp
new file mode 100644
index 00000000..8c01b4b3
--- /dev/null
+++ b/src/main/smp/sys/draw/Canvas.smp
@@ -0,0 +1,176 @@
+struct Canvas {
+ u32 width;
+ u32 height;
+ u32[] pixels;
+
+ new Canvas = { int w, int h ->
+ i64 count = w;
+ count *= h;
+ if( w<=0 || h<=0 || count>2147483647 ) libc._exit(1);
+ width = w;
+ height = h;
+ pixels = new u32[count];
+ };
+
+ val clear = { u32 color ->
+ for( int i=0; i<pixels#; i++ ) pixels[i] = color;
+ return self;
+ };
+
+ val pixel = { i64 x, i64 y, u32 color ->
+ if( 0<=x<width && 0<=y<height ) pixels[y*width+x] = color;
+ return self;
+ };
+
+ // Integer coordinates, top-left origin. Rectangle extents are half-open.
+ val fillRect = { i64 x, i64 y, i64 w, i64 h, u32 color ->
+ if( w<=0 || h<=0 ) return self;
+ i64 right = x; right += w;
+ i64 bottom = y; bottom += h;
+ if( right<=0 || bottom<=0 || x>=width || y>=height ) return self;
+ int x0 = x<0 ? 0 : x, y0 = y<0 ? 0 : y;
+ int x1 = right>width ? width : right, y1 = bottom>height ? height : bottom;
+ for( int py=y0; py<y1; py++ )
+ for( int px=x0; px<x1; px++ ) pixels[py*width+px] = color;
+ return self;
+ };
+
+ // Outline stays inside the rectangle. Wide borders become a filled rect.
+ val rect = { i64 x, i64 y, i64 w, i64 h, i64 thickness, u32 color ->
+ if( w<=0 || h<=0 || thickness<=0 ) return self;
+ if( thickness>=w || thickness>=h || thickness>=w-thickness || thickness>=h-thickness )
+ return fillRect(x,y,w,h,color);
+ fillRect(x,y,w,thickness,color);
+ fillRect(x,y+h-thickness,w,thickness,color);
+ fillRect(x,y+thickness,thickness,h-2*thickness,color);
+ return fillRect(x+w-thickness,y+thickness,thickness,h-2*thickness,color);
+ };
+
+ // Integer centers are pixel centers. Circle outlines extend inward.
+ val fillCircle = { i64 cx, i64 cy, int radius, u32 color ->
+ return _circle(cx,cy,radius,0,true,color);
+ };
+ val circle = { i64 cx, i64 cy, int radius, int thickness, u32 color ->
+ return _circle(cx,cy,radius,thickness,false,color);
+ };
+ val _circle = { i64 cx, i64 cy, int radius, int thickness, bool filled, u32 color ->
+ if( radius<0 || (!filled && thickness<=0) ) return self;
+ i64 left = cx; left -= radius;
+ i64 top = cy; top -= radius;
+ i64 right = cx; right += radius;
+ i64 bottom = cy; bottom += radius;
+ if( right<0 || bottom<0 || left>=width || top>=height ) return self;
+ int x0 = left<0 ? 0 : left, y0 = top<0 ? 0 : top;
+ int x1 = right>=width ? width-1 : right, y1 = bottom>=height ? height-1 : bottom;
+ i64 outer2 = radius;
+ outer2 *= radius;
+ i64 inner = filled ? -1 : radius-thickness;
+ for( int py=y0; py<=y1; py++ )
+ for( int px=x0; px<=x1; px++ ) {
+ i64 dx = px; dx -= cx;
+ i64 dy = py; dy -= cy;
+ i64 d2 = dx*dx+dy*dy;
+ if( d2<=outer2 && (inner<=0 || d2>inner*inner) ) pixels[py*width+px] = color;
+ }
+ return self;
+ };
+
+ // Round caps; a zero-length segment is a dot. Only the clipped bounding
+ // box is scanned. Floating distances avoid overflowing coordinate squares.
+ val line = { i64 x0, i64 y0, i64 x1, i64 y1, i64 thickness, u32 color ->
+ if( thickness<=0 ) return self;
+ i64 pad = thickness/2+1;
+ i64 left = x0<x1 ? x0 : x1; left -= pad;
+ i64 top = y0<y1 ? y0 : y1; top -= pad;
+ i64 right = x0>x1 ? x0 : x1; right += pad;
+ i64 bottom = y0>y1 ? y0 : y1; bottom += pad;
+ if( right<0 || bottom<0 || left>=width || top>=height ) return self;
+ int bx0 = left<0 ? 0 : left, by0 = top<0 ? 0 : top;
+ int bx1 = right>=width ? width-1 : right, by1 = bottom>=height ? height-1 : bottom;
+ for( int py=by0; py<=by1; py++ )
+ for( int px=bx0; px<=bx1; px++ )
+ if( _onLine(px,py,x0,y0,x1,y1,thickness) ) pixels[py*width+px] = color;
+ return self;
+ };
+
+ // Small 3x5 bitmap font for game labels. Lowercase maps to uppercase.
+ // Newline returns to x; unsupported characters draw a question mark.
+ val text = { i64 x, i64 y, u8[~] str, int scale, u32 color ->
+ if( scale<=0 ) return self;
+ i64 px = x, py = y;
+ for( int i=0; i<str#; i++ ) {
+ u8 c = str[i];
+ if( c=='\n' ) { px=x; py+=6*scale; continue; }
+ int bits = _glyph(c);
+ for( int row=0; row<5; row++ )
+ for( int col=0; col<3; col++ )
+ if( (bits >> (14-row*3-col)) & 1 )
+ fillRect(px+col*scale,py+row*scale,scale,scale,color);
+ px += 4*scale;
+ }
+ return self;
+ };
+};
+
+val _glyph = { u8 c ->
+ // Five octal digits: one 3-bit row per digit, top to bottom.
+ if( 'a'<=c<='z' ) c -= 'a'-'A';
+ if( c==' ' ) return 000000;
+ if( c=='0' ) return 075557;
+ if( c=='1' ) return 026227;
+ if( c=='2' ) return 061247;
+ if( c=='3' ) return 061216;
+ if( c=='4' ) return 055711;
+ if( c=='5' ) return 074616;
+ if( c=='6' ) return 034757;
+ if( c=='7' ) return 071222;
+ if( c=='8' ) return 075757;
+ if( c=='9' ) return 075716;
+ if( c=='A' ) return 025755;
+ if( c=='B' ) return 065656;
+ if( c=='C' ) return 034443;
+ if( c=='D' ) return 065556;
+ if( c=='E' ) return 074647;
+ if( c=='F' ) return 074644;
+ if( c=='G' ) return 034553;
+ if( c=='H' ) return 055755;
+ if( c=='I' ) return 072227;
+ if( c=='J' ) return 011152;
+ if( c=='K' ) return 055655;
+ if( c=='L' ) return 044447;
+ if( c=='M' ) return 057755;
+ if( c=='N' ) return 057775;
+ if( c=='O' ) return 025552;
+ if( c=='P' ) return 065644;
+ if( c=='Q' ) return 025573;
+ if( c=='R' ) return 065655;
+ if( c=='S' ) return 034216;
+ if( c=='T' ) return 072222;
+ if( c=='U' ) return 055557;
+ if( c=='V' ) return 055552;
+ if( c=='W' ) return 055775;
+ if( c=='X' ) return 055255;
+ if( c=='Y' ) return 055222;
+ if( c=='Z' ) return 071247;
+ if( c=='-' ) return 000700;
+ if( c=='.' ) return 000002;
+ if( c==':' ) return 002020;
+ if( c=='!' ) return 022202;
+ if( c=='?' ) return 061202;
+ return 061202;
+};
+
+// Pixel coverage for a round-ended segment, separate from traversal.
+val _onLine = { i64 px, i64 py, i64 x0, i64 y0, i64 x1, i64 y1, i64 thickness ->
+ f64 dx = x1; dx -= x0;
+ f64 dy = y1; dy -= y0;
+ f64 vx = px; vx -= x0;
+ f64 vy = py; vy -= y0;
+ f64 length2 = dx*dx+dy*dy;
+ bool point = x0==x1 && y0==y1;
+ f64 t = point ? 0.0 : (vx*dx+vy*dy)/length2;
+ t = t<0 ? 0.0 : t>1 ? 1.0 : t;
+ f64 ex = vx-t*dx, ey = vy-t*dy;
+ f64 half = thickness/2.0;
+ return ex*ex+ey*ey<=half*half;
+};
diff --git a/src/main/smp/sys/gfx.smp b/src/main/smp/sys/gfx.smp
new file mode 100644
index 00000000..cde2cc0d
--- /dev/null
+++ b/src/main/smp/sys/gfx.smp
@@ -0,0 +1,42 @@
+// Minimal pixel-window interface; drawing belongs in Simple.
+// One fixed-size window, main thread only. Pixel format is 0x00RRGGBB.
+// open/present/poll return OK, CLOSED (normal user close/Esc), or ERROR.
+int OK = 0;
+int CLOSED = 1;
+int ERROR = -1;
+int LEFT = 1;
+int ESCAPE = 256;
+
+{ i64 u32 u32 -> i32 } simple_gfx_open = "C";
+{ i64 u32 -> i32 } simple_gfx_present = "C";
+{ i32 -> i32 } simple_gfx_poll = "C";
+{ i32 } simple_gfx_mouse_x = "C";
+{ i32 } simple_gfx_mouse_y = "C";
+{ i32 -> i32 } simple_gfx_button = "C";
+{ i32 -> i32 } simple_gfx_key = "C";
+{ i32 } simple_gfx_close = "C";
+{ i64 -> i32 } simple_gfx_wait = "C";
+
+// Failure prints a diagnostic to stderr and returns ERROR; callers decide
+// whether to exit or fall back to text. No native library is loaded until here.
+val open = { u8[~] title, u32 width, u32 height ->
+ i64 ptr = aryu8.str0(title);
+ simple_gfx_open(ptr,width,height);
+};
+// Array length must be width*height. The native layer copies for OS repaints;
+// it never keeps a pointer into the Simple heap after this call returns.
+val present = { u32[~] pixels ->
+ i64 ptr = pixels;
+ simple_gfx_present(ptr,pixels#);
+};
+// wait=true waits up to 17ms, waking early for input to avoid a busy loop.
+val poll = { bool wait -> simple_gfx_poll(wait); };
+val mouseX = { -> simple_gfx_mouse_x(); };
+val mouseY = { -> simple_gfx_mouse_y(); };
+val button = { int button -> simple_gfx_button(button); };
+// Printable keys use uppercase ASCII; special keys use MiniFB's key codes.
+val key = { int key -> simple_gfx_key(key); };
+val close = { -> simple_gfx_close(); };
+// Wait for input or at most timeout milliseconds; 0 polls, -1 waits indefinitely.
+// This blocks the game thread, dispatches window events, and may wake early.
+val wait = { i64 timeout -> simple_gfx_wait(timeout); };
diff --git a/src/main/smp/sys/text.smp b/src/main/smp/sys/text.smp
index a414ff45..a28edf6d 100644
--- a/src/main/smp/sys/text.smp
+++ b/src/main/smp/sys/text.smp
@@ -96,3 +96,8 @@ val f64 = { f64 x -> end(floating(begin(),x, 0 ,0,-1)); };
val char = { i64 x -> end(integer (begin(),x,'c',0,-1)); };
val bool = { bool x -> end(integer (begin(),x,'b',0,-1)); };
val hex = { i64 x -> end(integer (begin(),x,'x',0,-1)); };
+
+// Time selectors occupy spec bits 16..23; padding shares string formatting.
+val time = { !aryu8 b, i64 value, i64 spec, i64 width, i64 precision ->
+ string(b,sys.time.format(value,(spec>>>16)&255),spec,width,-1);
+};
diff --git a/src/main/smp/sys/time.smp b/src/main/smp/sys/time.smp
new file mode 100644
index 00000000..cdcb5055
--- /dev/null
+++ b/src/main/smp/sys/time.smp
@@ -0,0 +1,191 @@
+// Instants: bare i64 UTC Unix-epoch milliseconds. Durations: i64 milliseconds.
+// Monotonic readings are a separate clock domain, for deadlines/elapsed time.
+val DateTime = DateTime;
+val Timers = Timers;
+i64 SECOND=1000, MINUTE=60000, HOUR=3600000, DAY=86400000;
+i64 MIN=-62135596800000, MAX=253402300799999; // Calendar years 1..9999.
+i64 LIMIT=9223372036854775807;
+int ISO=0, CLOCK12=1, CLOCK24=2, ELAPSED=3, DATE=4, CLOCK24_SECONDS=5;
+{ i64 } simple_time_now = "C";
+{ i64 } simple_time_mono = "C";
+{ i64 } simple_time_nano = "C";
+{ i64 i32 -> i32 } simple_time_sleep = "C";
+{ i64 i64 -> i32 } simple_time_local = "C";
+{ i64 i64 u32 -> i32 } simple_time_zone = "C";
+val now = { -> simple_time_now(); };
+val monotonic = { -> simple_time_mono(); };
+val nanoTime = { -> simple_time_nano(); };
+val sleep = { i64 ms -> simple_time_sleep(ms,0); };
+val sleepUntil = { i64 deadline -> simple_time_sleep(deadline,1); };
+
+// Signed duration conversions. Overflow returns -1; reverse conversions truncate
+// toward zero. An exact duration day is 24 hours, independent of DST.
+val _scale = { i64 n, i64 unit ->
+ if( n>LIMIT/unit || n<(-LIMIT-1)/unit ) return -1;
+ return n*unit;
+};
+val seconds = { i64 n -> _scale(n,SECOND); };
+val minutes = { i64 n -> _scale(n,MINUTE); };
+val hours = { i64 n -> _scale(n,HOUR); };
+val days = { i64 n -> _scale(n,DAY); };
+val toSeconds = { i64 ms -> ms/SECOND; };
+val toMinutes = { i64 ms -> ms/MINUTE; };
+val toHours = { i64 ms -> ms/HOUR; };
+val toDays = { i64 ms -> ms/DAY; };
+val fromNanos = { i64 ns -> ns/1000000; };
+val toNanos = { i64 ms -> _scale(ms,1000000); };
+// Clamp to zero once due, and saturate for widely separated i64 readings.
+val remaining = { i64 deadline, i64 current ->
+ if( deadline<=current ) return 0;
+ if( current<0 && deadline>LIMIT+current ) return LIMIT;
+ return deadline-current;
+};
+val _mod = { i64 n, i64 unit -> n-n/unit*unit; };
+val leap = { int year -> _mod(year,4)==0 && (_mod(year,100)!=0 || _mod(year,400)==0); };
+val monthDays = { int year, int month ->
+ if( month<1 || month>12 ) return 0;
+ return month==2 ? (leap(year) ? 29 : 28) :
+ month==4 || month==6 || month==9 || month==11 ? 30 : 31;
+};
+
+// Gregorian civil dates, expressed using 400-year eras; no per-year loops.
+val _days = { i64 year, i64 month, i64 day ->
+ year -= month<=2 ? 1 : 0;
+ i64 era=year/400, y=year-era*400;
+ i64 mp=month+(month>2 ? -3 : 9);
+ return era*146097+y*365+y/4-y/100+(153*mp+2)/5+day-1-719468;
+};
+val valid = { int year, int month, int day, int hour, int minute, int second, int millisecond ->
+ return year>=1 && year<=9999 && month>=1 && month<=12 && day>=1 && day<=monthDays(year,month) &&
+ hour>=0 && hour<=23 && minute>=0 && minute<=59 && second>=0 && second<=59 &&
+ millisecond>=0 && millisecond<=999;
+};
+val makeUTC = { int year, int month, int day, int hour, int minute, int second, int millisecond ->
+ if( !valid(year,month,day,hour,minute,second,millisecond) ) return -1;
+ return _days(year,month,day)*DAY+hour*HOUR+minute*MINUTE+second*SECOND+millisecond;
+};
+val utc = { i64 instant ->
+ val d=DateTime.empty();
+ if( instant<MIN || instant>MAX ) return d;
+ i64 day=instant/DAY, rest=_mod(instant,DAY);
+ if( rest<0 ) { rest+=DAY; day--; }
+ i64 z=day+719468, era=z/146097, doe=z-era*146097;
+ i64 y=(doe-doe/1460+doe/36524-doe/146096)/365;
+ i64 doy=doe-(365*y+y/4-y/100), mp=(5*doy+2)/153;
+ d.day=doy-(153*mp+2)/5+1;
+ d.month=mp+(mp<10 ? 3 : -9);
+ d.year=y+era*400+(d.month<=2 ? 1 : 0);
+ d.hour=rest/HOUR; d.minute=_mod(rest/MINUTE,60);
+ d.second=_mod(rest/SECOND,60); d.millisecond=_mod(rest,SECOND);
+ d.weekday=_mod(day+4,7);
+ if( d.weekday<0 ) d.weekday+=7;
+ return d;
+};
+val local = { i64 instant ->
+ val d=DateTime.empty();
+ val f=new i64[10];
+ i64 ptr=f;
+ if( simple_time_local(instant,ptr)!=0 ) return d;
+ d.year=f[0]; d.month=f[1]; d.day=f[2];
+ d.hour=f[3]; d.minute=f[4]; d.second=f[5]; d.millisecond=f[6];
+ d.weekday=f[7]; d.offsetSeconds=f[8]; d.daylight=f[9]!=0;
+ return d;
+};
+val zoneName = { i64 instant ->
+ val bytes=new u8[128];
+ i64 ptr=bytes;
+ int n=simple_time_zone(instant,ptr,bytes#);
+ if( n<0 ) return "";
+ val b=new aryu8();
+ for( int i=0; i<n; i++ ) b.add(bytes[i]);
+ return text.end(b);
+};
+
+val _digits = { !aryu8 b, i64 n, int width ->
+ text.integer(b,n,'d'|4096,width,-1);
+};
+val _format = { i64 instant, int style, bool localZone ->
+ val d=localZone ? local(instant) : utc(instant);
+ if( d.year==0 || style<0 || style>5 || style==3 ) return "<invalid time>";
+ val b=new aryu8();
+ if( style==4 ) {
+ _digits(b,d.day,2).add('/'); _digits(b,d.month,2).add('/'); _digits(b,_mod(d.year,100),2);
+ } else {
+ if( style==0 ) {
+ _digits(b,d.year,4).add('-'); _digits(b,d.month,2).add('-'); _digits(b,d.day,2).add('T');
+ }
+ int hour=d.hour;
+ if( style==1 ) { hour=_mod(hour,12); if( hour==0 ) hour=12; }
+ _digits(b,hour,style==1 ? 1 : 2).add(':'); _digits(b,d.minute,2);
+ if( style==0 || style==5 ) { b.add(':'); _digits(b,d.second,2); }
+ if( style==1 ) b.addAry(d.hour<12 ? "am" : "pm");
+ if( style==0 ) {
+ b.add('.'); _digits(b,d.millisecond,3);
+ if( d.offsetSeconds==0 ) b.add('Z');
+ else {
+ i64 offset=d.offsetSeconds;
+ b.add(offset<0 ? '-' : '+');
+ if( offset<0 ) offset=-offset;
+ _digits(b,offset/3600,2).add(':'); _digits(b,_mod(offset/60,60),2);
+ if( _mod(offset,60)!=0 ) { b.add(':'); _digits(b,_mod(offset,60),2); }
+ }
+ }
+ }
+ return text.end(b);
+};
+
+val elapsed = { i64 ms ->
+ val b=new aryu8();
+ // Nonpositive arithmetic handles the minimum i64 without negating it.
+ if( ms<0 ) b.add('-'); else ms=-ms;
+ _digits(b,-(ms/MINUTE),2).add(':');
+ _digits(b,-_mod(ms/SECOND,60),2).add('.'); _digits(b,-_mod(ms,SECOND),3);
+ return text.end(b);
+};
+val format = { i64 instant, int style ->
+ if( style==ELAPSED ) return elapsed(instant);
+ return _format(instant,style,false);
+};
+val formatLocal = { i64 instant, int style ->
+ if( style==ELAPSED ) return elapsed(instant);
+ return _format(instant,style,true);
+};
+
+val _number = { u8[~] s, i64 at, int count ->
+ if( at<0 || count<1 || at+count>s# ) return -1;
+ int n=0;
+ for( int i=0; i<count; i++ ) {
+ int c=s[at+i];
+ if( c<'0' || c>'9' ) return -1;
+ n=n*10+c-'0';
+ }
+ return n;
+};
+// Keep suffix validation separate from calendar construction.
+val _parseZone_noInline = { u8[~] s, int at, i64 instant ->
+ if( at<0 || at>=s# ) return -1;
+ i64 offset=0;
+ if( s[at]=='Z' ) { if( at+1!=s# ) return -1; }
+ else {
+ int sign=s[at]=='+' ? 1 : s[at]=='-' ? -1 : 0;
+ if( !sign || (s#!=at+6 && s#!=at+9) || s[at+3]!=':' ) return -1;
+ int oh=_number(s,at+1,2), om=_number(s,at+4,2), os=0;
+ if( s#==at+9 ) { if( s[at+6]!=':' ) return -1; os=_number(s,at+7,2); }
+ if( oh<0 || oh>23 || om<0 || om>59 || os<0 || os>59 ) return -1;
+ offset=sign*(oh*HOUR+om*MINUTE+os*SECOND);
+ }
+ instant-=offset;
+ return instant<MIN || instant>MAX ? -1 : instant;
+};
+
+// Strict ISO absolute instants only. -1 is reserved for failure (and therefore
+// cannot distinguish the valid instant one millisecond before Unix epoch).
+val parse = { u8[~] s ->
+ if( s#<20 || s[4]!='-' || s[7]!='-' || s[10]!='T' || s[13]!=':' || s[16]!=':' ) return -1;
+ int y=_number(s,0,4), m=_number(s,5,2), d=_number(s,8,2);
+ int h=_number(s,11,2), min=_number(s,14,2), sec=_number(s,17,2);
+ bool fraction=s[19]=='.';
+ int ms=fraction ? _number(s,20,3) : 0;
+ if( !valid(y,m,d,h,min,sec,ms) ) return -1;
+ return _parseZone_noInline(s,fraction ? 23 : 19,makeUTC(y,m,d,h,min,sec,ms));
+};
diff --git a/src/main/smp/sys/time/DateTime.smp b/src/main/smp/sys/time/DateTime.smp
new file mode 100644
index 00000000..5d117467
--- /dev/null
+++ b/src/main/smp/sys/time/DateTime.smp
@@ -0,0 +1,11 @@
+// Calendar view of an instant. Month/day start at 1; weekday is Sunday=0.
+// year==0 means out of the supported calendar range (years 1 through 9999).
+struct DateTime {
+ int !year=0, !month=0, !day=0;
+ int !hour=0, !minute=0, !second=0, !millisecond=0;
+ int !weekday=0, !offsetSeconds=0;
+ bool !daylight=false;
+};
+
+// Factory in the defining unit, so callers need no forward instance type.
+val empty = { -> new DateTime(); };
diff --git a/src/main/smp/sys/time/Timers.smp b/src/main/smp/sys/time/Timers.smp
new file mode 100644
index 00000000..b98c96ef
--- /dev/null
+++ b/src/main/smp/sys/time/Timers.smp
@@ -0,0 +1,55 @@
+// Cooperative timers. poll returns IDs; callbacks never run on another thread.
+struct Timers {
+ i64[] !_ids=new i64[8], !_due=new i64[8], !_period=new i64[8];
+ i64 !_next=1;
+
+ val _add = { i64 current, i64 delay, i64 period ->
+ if( current<0 || delay<0 || current>time.LIMIT-delay || _next==time.LIMIT ) return -1;
+ int slot=0;
+ while( slot<_ids# && _ids[slot]!=0 ) slot++;
+ if( slot==_ids# ) {
+ if( _ids#>1073741823 ) return -1;
+ val ids=new i64[_ids#*2], due=new i64[_ids#*2], periods=new i64[_ids#*2];
+ for( int i=0; i<_ids#; i++ ) { ids[i]=_ids[i]; due[i]=_due[i]; periods[i]=_period[i]; }
+ _ids=ids; _due=due; _period=periods;
+ // Retry against the newly published arrays after growing.
+ return _add(current,delay,period);
+ }
+ i64 id=_next++;
+ _ids[slot]=id; _due[slot]=current+delay; _period[slot]=period;
+ return id;
+ };
+ // Explicit-clock versions make game logic and tests deterministic.
+ val afterAt = { i64 current, i64 delay -> _add(current,delay,0); };
+ val everyAt = { i64 current, i64 period -> period<=0 ? -1 : _add(current,period,period); };
+ val after = { i64 delay -> afterAt(time.monotonic(),delay); };
+ val every = { i64 period -> everyAt(time.monotonic(),period); };
+ val cancel = { i64 id ->
+ if( id<=0 ) return false;
+ for( int i=0; i<_ids#; i++ ) if( _ids[i]==id ) { _ids[i]=0; return true; }
+ return false;
+ };
+ val nextDeadline = { ->
+ i64 next=-1;
+ for( int i=0; i<_ids#; i++ )
+ if( _ids[i]!=0 && (next<0 || _due[i]<next) ) next=_due[i];
+ return next;
+ };
+ // Earliest deadline first; equal deadlines use registration order.
+ // Repeats skip missed ticks and retain their phase, avoiding bursts/drift.
+ val poll = { i64 current ->
+ int slot=-1;
+ for( int i=0; i<_ids#; i++ )
+ if( _ids[i]!=0 && _due[i]<=current && (slot<0 || _due[i]<_due[slot] ||
+ (_due[i]==_due[slot] && _ids[i]<_ids[slot])) ) slot=i;
+ if( slot<0 ) return -1;
+ i64 id=_ids[slot], period=_period[slot];
+ if( period==0 ) _ids[slot]=0;
+ else {
+ i64 steps=(current-_due[slot])/period;
+ if( steps>=(time.LIMIT-_due[slot])/period ) _ids[slot]=0;
+ else _due[slot]+=(steps+1)*period;
+ }
+ return id;
+ };
+};