From 4f749f192601a887d25954b9fc5e5ae9820ed19f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 14:53:57 +0000 Subject: [PATCH] Add built-in MEM command similar to FreeDOS MEM Report conventional, upper (UMB), reserved, extended (XMS) and expanded (EMS) memory, the largest executable program size and the largest available upper memory block, with /CLASSIFY, /FREE, /DEBUG, /PAGE and /? switches. The report is laid out like the one real FreeDOS MEM prints, and dosemu2's own test suite (test/func_comcom_internal.py) compares the two tool's figures, so they follow MEM.EXE's conventions: - memory is accounted for, and displayed, in KB. Each source is converted to KB once, where it is read: INT 12h, XMS and INT 15h/E820h already report KB, EMS counts 16KB pages, and the MCB chain is accumulated in paragraphs and converted at print time, rounding to nearest. The largest blocks and the EMS totals also carry the byte count MEM.EXE prints next to them; - conventional memory ends at the 640K line, so its total is capped there whatever INT 12h reports (dosemu2 with $_dosmem above 640 reports 736K), and an MCB crossing the line is split between the two regions rather than charged whole to the one its first paragraph is in: with UMBs linked, DOS merges the top of conventional memory with the adjacent upper block into a single MCB; - above it, the first megabyte is divided the way MEM.EXE divides it: 0xA000-0xDFFF is "Upper" and 0xE000-0xFFFF is "Reserved", both fixed regions rather than sums of the blocks found in them, with free UMBs counting towards upper memory wherever they sit; - the largest free block is measured over runs of adjacent free blocks, which DOS merges when it allocates - right after a program exits, the memory it used is several adjacent free MCBs that have not been merged yet, and reporting the largest of those understates what a program can actually get by a wide margin. Extended memory has no XMS call reporting the installed total, so it comes from INT 15h/E820h, same as FreeDOS MEM: under dosemu2 the older E801h/88h calls report a much smaller compatibility figure than the pool that is actually configured. UMBs are linked for the scan through INT 21h/58h(03h) directly, so the allocation strategy is never touched. The MCB walk stops if the chain fails to advance past 0xffff, rather than wrapping into low memory and counting it twice. MEM /CLASSIFY groups blocks by their owner. DOS 4+ records the owner's name only in the MCB that holds the program itself, so the names are collected up front and looked up by owner: naming each block on its own listed one module twice, once as COMMAND and once as PSP-xxxx. The placeholder blocks that keep the upper memory chain linked across the video memory and the option ROMs ("SC", owned by the kernel) are holes in the address space rather than memory, so they are listed but not counted, and the memory below the first MCB is charged to SYSTEM, the way MEM.EXE charges it. /PAGE follows the cursor the way "dir /p" does instead of counting the lines printed, since the report starts wherever the command line left the cursor and long lines wrap. Built-in commands are handed only the first argument, so "mem /d /p" would arrive as just "/d"; get_cmd_args() exposes the full argument string for commands taking several switches. tests/compare_mem.sh runs this MEM and the real FreeDOS MEM.EXE in one dosemu2 session and compares them, as a stand-alone version of the dosemu2 test. Both dosemu2 and FreeDOS come from the dosemu2 PPA: the FreeDOS userspace, where MEM.EXE comes from, is installed by dosemu2's own insfdusr into a private HOME under the cache dir, and DOSEMU2_COMCOM_DIR points dosemu2 at the build under test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kg5AbDqjCHbxSZzSpV6aRA --- djgpp/makefile | 2 +- src/command.c | 9 +- src/command.h | 5 + src/makefile.inc | 4 +- src/mem.c | 958 +++++++++++++++++++++++++++++++++++++++++++ src/mem.h | 25 ++ tests/compare_mem.sh | 212 ++++++++++ 7 files changed, 1211 insertions(+), 4 deletions(-) create mode 100644 src/mem.c create mode 100644 src/mem.h create mode 100755 tests/compare_mem.sh diff --git a/djgpp/makefile b/djgpp/makefile index 9d10bd6..d53554a 100644 --- a/djgpp/makefile +++ b/djgpp/makefile @@ -12,7 +12,7 @@ C_OPT = -Wall -O2 -Wmissing-declarations -Wwrite-strings -I $(SRCDIR) -I . \ # avoid CMOVxx instructions C_OPT += -march=i386 LINK_OPT = -SRCS = $(SRCDIR)/command.c $(SRCDIR)/cmdbuf.c $(SRCDIR)/ms.c $(SRCDIR)/djterm.c \ +SRCS = $(SRCDIR)/command.c $(SRCDIR)/cmdbuf.c $(SRCDIR)/ms.c $(SRCDIR)/djterm.c $(SRCDIR)/mem.c \ $(SRCDIR)/env.c $(SRCDIR)/psp.c $(SRCDIR)/umb.c $(SRCDIR)/ae0x.c \ $(SRCDIR)/compl.c $(SRCDIR)/clip.c memmem.c fmemcpy.c findclos.c ASSRCS = $(SRCDIR)/asm.S $(SRCDIR)/int23.S $(SRCDIR)/int0.S $(SRCDIR)/int75.S \ diff --git a/src/command.c b/src/command.c index 25884bd..1c498e4 100644 --- a/src/command.c +++ b/src/command.c @@ -97,6 +97,7 @@ #include "ae0x.h" #include "compl.h" #include "clip.h" +#include "mem.h" #include "command.h" /* @@ -305,6 +306,7 @@ struct built_in_cmd cmd_table[] = {"loadhigh", perform_loadhigh, "", "load program to UMB"}, {"md", perform_md, "", "create directory"}, {"mkdir", perform_md, "", "create directory"}, + {"mem", perform_mem, " [/c] [/f] [/d] [/p]", "display memory statistics"}, {"move", perform_move, "", "move file"}, {"more", perform_more, "", "scroll-pause long output"}, {"mouseopt", perform_mouseopt, "", "mouse options"}, @@ -412,7 +414,12 @@ static int has_wildcard(char *s) return false; } -static void reset_batfile_call_stack(void) +const char *get_cmd_args(void) + { + return cmd_args; + } + +void reset_batfile_call_stack(void) { static int first_time = true; int ba; diff --git a/src/command.h b/src/command.h index e416a33..064db53 100644 --- a/src/command.h +++ b/src/command.h @@ -475,6 +475,11 @@ struct built_in_cmd extern struct built_in_cmd cmd_table[]; extern const int CMD_TABLE_COUNT; +extern void reset_batfile_call_stack(void); +/* full argument string of the command being executed: built-in commands + * are handed only the first argument, so anything that takes more than + * one switch needs this */ +extern const char *get_cmd_args(void); unsigned short keyb_get_shift_states(void); diff --git a/src/makefile.inc b/src/makefile.inc index edb366c..0ad30eb 100644 --- a/src/makefile.inc +++ b/src/makefile.inc @@ -16,10 +16,10 @@ DJCFLAGS = -Wall -O2 -Wmissing-declarations -Wwrite-strings \ DJCPPFLAGS += -I. -I$(srcdir) -DCOMCOM_VERSION=\"$(VERSION)\" DJASFLAGS += -I. -I$(srcdir) DJASCPPFLAGS += -I. -I$(srcdir) -SOURCES = command.c cmdbuf.c mouse.c env.c psp.c umb.c ae0x.c compl.c clip.c \ +SOURCES = command.c cmdbuf.c mouse.c env.c psp.c umb.c ae0x.c compl.c clip.c mem.c \ djterm.c thunks_a.c thunks_c.c HEADERS = $(addprefix $(srcdir)/,ae0x.h cmdbuf.h compl.h psp.h command.h env.h mouse.h umb.h \ - glob_asm.h asm.h) + mem.h glob_asm.h asm.h) PDHDR = $(srcdir)/asm.h GLOB_ASM = $(srcdir)/glob_asm.h OBJECTS = $(SOURCES:.c=.o) diff --git a/src/mem.c b/src/mem.c new file mode 100644 index 0000000..2138c79 --- /dev/null +++ b/src/mem.c @@ -0,0 +1,958 @@ +/* + * comcom64 - 64bit command.com + * mem.c: FreeDOS-like MEM command implementation + * Copyright (C) 2026 @stsp/JulesAI, @stuaxo/Claude + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "asm.h" +#include "command.h" +#include "mem.h" + +/* + * Everything here is accounted for, and displayed, in KB - the same unit + * real FreeDOS MEM reports. Each source of numbers is converted to KB + * exactly once, right where it is read: INT 12h and XMS/INT 15h already + * speak KB, EMS counts 16KB pages, and the MCB chain counts 16-byte + * paragraphs. Nothing is converted from KB to bytes and back again on + * its way to the screen. + */ +#define PARAS_PER_KB 64 +#define EMS_PAGE_KB 16 + +/* + * Conventional memory ends at the 640K line - segment 0xA000 - and + * everything at or above it is upper memory. Nothing we report as + * conventional may exceed that, whatever the BIOS or the MCB chain + * says. + */ +#define UMB_START_SEG 0xA000 +#define CONV_MAX_KB (UMB_START_SEG / PARAS_PER_KB) + +/* + * Above conventional memory, FreeDOS MEM splits the first megabyte the + * way MS-DOS MEM did: 0xA000-0xDFFF is "Upper", and 0xE000-0xFFFF is + * "Reserved" - the BIOS and option ROM area, reported as entirely in + * use. Both are fixed-size regions, not sums of the blocks found in + * them, and free UMBs count towards upper memory wherever they sit. + * Report them the same way, so our numbers can be compared with + * MEM.EXE's directly. + */ +#define RESERVED_START_SEG 0xE000 +#define UMB_TOTAL_KB ((RESERVED_START_SEG - UMB_START_SEG) / PARAS_PER_KB) +#define RESERVED_TOTAL_KB ((0x10000 - RESERVED_START_SEG) / PARAS_PER_KB) + +/* Rounds to nearest, as FreeDOS MEM does. */ +static uint32_t paras_to_kb(uint32_t paras) +{ + return (paras + PARAS_PER_KB / 2) / PARAS_PER_KB; +} + +struct MCB { + char id; /* 'M' (0x4D) or 'Z' (0x5A) */ + uint16_t owner_psp; /* 0 = free, 8 = system, else PSP segment */ + uint16_t size; /* Size in 16-byte paragraphs */ + char reserved[3]; + char name[8]; /* Owner/program name in DOS 4+ */ +} __attribute__((packed)); + +struct module_entry { + uint16_t owner_psp; + char name[16]; /* matches get_mcb_name()'s name_out buffer size */ + uint32_t conv_paras; + uint32_t umb_paras; +}; + +static int opt_classify = 0; +static int opt_free = 0; +static int opt_debug = 0; +static int opt_page = 0; +static int page_bottom = 0; +static int page_stop = 0; + +static void reset_page(void) +{ + struct text_info txinfo; + + page_stop = 0; + gettextinfo(&txinfo); + page_bottom = txinfo.winbottom; + /* start the report on a screen of its own, as "dir /p" does, so the + * first page is a full one wherever the command line left the cursor */ + if (opt_page) + clrscr(); +} + +/* + * Pause on reaching the last line of the window, rather than counting + * the lines printed so far: our output starts wherever the command line + * left the cursor and long lines wrap, so a counter that starts at zero + * pauses in the wrong place - or, for the short default report, never + * pauses at all while the earlier lines scroll away. This is what + * "dir /p" does, so /PAGE now behaves the same in both commands. + */ +static void print_line(const char *str) +{ + int c; + + if (page_stop) + return; + printf("%s\n", str); + if (!opt_page) + return; + + /* the cursor only moves once stdout reaches the screen */ + fflush(stdout); + if (wherey() < page_bottom) + return; + + printf("Press any key to continue, or q to stop..."); + fflush(stdout); + c = getch(); + if (c == 3 || c == 27 || toupper(c) == 'Q') + { + printf("\n"); + page_stop = 1; + return; + } + clrscr(); +} + +static void format_num(uint32_t val, char *buf, size_t buf_size) +{ + char tmp[32]; + snprintf(tmp, sizeof(tmp), "%lu", (unsigned long)val); + int len = strlen(tmp); + int dest_idx = 0; + int i; + + for (i = 0; i < len; i++) + { + if (i > 0 && (len - i) % 3 == 0) + { + if (dest_idx < (int)buf_size - 1) + buf[dest_idx++] = ','; + } + if (dest_idx < (int)buf_size - 1) + buf[dest_idx++] = tmp[i]; + } + buf[dest_idx] = '\0'; +} + +static void format_kb(uint32_t kb, char *buf, size_t buf_size) +{ + size_t len; + + format_num(kb, buf, buf_size); + len = strlen(buf); + if (len < buf_size - 1) + { + buf[len] = 'K'; + buf[len + 1] = '\0'; + } +} + +/* + * MEM.EXE prints the largest blocks and the EMS totals in KB with the + * byte count next to it, and the dosemu2 test suite parses that byte + * count, so print both. This is the only place a KB figure is turned + * back into bytes, and only for display. + */ +static void print_kb_and_bytes(const char *label, uint32_t kb, uint32_t bytes) +{ + char kb_str[24], b_str[24], buf[160]; + + format_kb(kb, kb_str, sizeof(kb_str)); + format_num(bytes, b_str, sizeof(b_str)); + snprintf(buf, sizeof(buf), "%-36s%8s (%s bytes)", label, kb_str, b_str); + print_line(buf); +} + +struct psp_name { + uint16_t psp; + char name[9]; +}; + +#define MAX_PSP_NAMES 64 + +/* Copies the MCB's owner name out, if DOS put a usable one there. */ +static int copy_mcb_name(const struct MCB *mcb, char *out, size_t out_size) +{ + int i; + + for (i = 0; i < 8; i++) + { + if (mcb->name[i] == 0 || mcb->name[i] == ' ') + break; + if (!isprint((unsigned char)mcb->name[i]) || (size_t)i >= out_size - 1) + return 0; + out[i] = mcb->name[i]; + } + out[i] = '\0'; + return i > 0; +} + +/* + * DOS 4+ stores the owner's name in the MCB of the block holding the + * program itself - the one whose PSP is its first paragraph. A + * program's other blocks (its environment, anything it allocates later) + * carry no usable name, so collect the names up front and then look + * them up by owner. Naming each block on its own instead is what made + * MEM /CLASSIFY list one module twice, once as COMMAND and once as + * PSP-xxxx. + */ +static int collect_psp_names(uint16_t first_mcb_seg, struct psp_name *names, + int max_names) +{ + uint16_t seg = first_mcb_seg; + int num = 0; + + while (seg != 0 && num < max_names) + { + struct MCB mcb; + + dosmemget(seg << 4, sizeof(mcb), &mcb); + if (mcb.id != 'M' && mcb.id != 'Z') + break; + + if (mcb.owner_psp == seg + 1 && + copy_mcb_name(&mcb, names[num].name, sizeof(names[num].name))) + names[num++].psp = mcb.owner_psp; + + if (mcb.id == 'Z') + break; + if (seg + 1 + mcb.size > 0xffff) + break; + seg += 1 + mcb.size; + } + return num; +} + +/* + * DOS keeps the upper memory chain linked across the areas that are not + * RAM at all - the video memory and the option ROMs - with placeholder + * blocks owned by the kernel and named "SC". They are holes in the + * address space rather than memory in use, and neither MEM.EXE nor + * dosemu2's own debugger counts them, so skip them too. + */ +static int mcb_is_link(const struct MCB *mcb) +{ + char name[9]; + + return mcb->owner_psp == 8 && copy_mcb_name(mcb, name, sizeof(name)) && + strcmp(name, "SC") == 0; +} + +static void get_mcb_name(const struct MCB *mcb, const struct psp_name *names, + int num_names, char *name_out, size_t name_out_size) +{ + int i; + + memset(name_out, 0, name_out_size); + + if (mcb->owner_psp == 0) + { + strncpy(name_out, "Free", name_out_size - 1); + return; + } + + /* + * Blocks owned by DOS carry the name of the driver loaded into them, + * where there is one - that is how MEM.EXE tells a driver apart from + * the kernel's own memory. "SC" and "SD" are not drivers though, they + * are the kernel's own markers for its link blocks and its data. + */ + if (mcb->owner_psp == 8) + { + if (mcb_is_link(mcb)) + strncpy(name_out, "LINK", name_out_size - 1); + else if (!copy_mcb_name(mcb, name_out, name_out_size) || + strcmp(name_out, "SD") == 0) + strncpy(name_out, "SYSTEM", name_out_size - 1); + return; + } + + for (i = 0; i < num_names; i++) + { + if (names[i].psp == mcb->owner_psp) + { + strncpy(name_out, names[i].name, name_out_size - 1); + return; + } + } + + if (copy_mcb_name(mcb, name_out, name_out_size)) + return; + + /* Try extracting program name from owner PSP environment block */ + uint16_t env_seg = _farpeekw(_dos_ds, (mcb->owner_psp << 4) + 0x2c); + if (env_seg != 0) + { + uint32_t env_addr = env_seg << 4; + uint32_t offset = 0; + uint8_t b1 = _farpeekb(_dos_ds, env_addr + offset); + uint8_t b2 = _farpeekb(_dos_ds, env_addr + offset + 1); + + while (offset < 0x7fff) + { + if (b1 == 0 && b2 == 0) + { + offset += 2; + uint16_t count = _farpeekw(_dos_ds, env_addr + offset); + if (count == 1) + { + offset += 2; + char path[128]; + int p_idx = 0; + while (p_idx < sizeof(path) - 1) + { + char ch = _farpeekb(_dos_ds, env_addr + offset + p_idx); + if (ch == 0) + break; + path[p_idx++] = ch; + } + path[p_idx] = '\0'; + + char *fname = strrchr(path, '\\'); + if (!fname) + fname = strrchr(path, '/'); + if (!fname) + fname = path; + else + fname++; + + char *dot = strchr(fname, '.'); + if (dot) + *dot = '\0'; + + if (strlen(fname) > 0) + { + strncpy(name_out, fname, name_out_size - 1); + return; + } + } + break; + } + offset++; + b1 = b2; + b2 = _farpeekb(_dos_ds, env_addr + offset + 1); + } + } + + snprintf(name_out, name_out_size, "PSP-%04X", mcb->owner_psp); +} + +struct e820_entry { + uint64_t base; + uint64_t length; + uint32_t type; + uint32_t ext_attr; +} __attribute__((packed)); + +#define E820_TYPE_USABLE 1 +#define E820_MAX_ENTRIES 64 + +/* + * XMS only ever reports free extended memory (function 08h gives the + * largest free block and total free, nothing else) - there is no XMS + * call for total installed extended memory, so total has to come from + * the BIOS. Prefer the INT 15h/E820h system memory map, same as real + * FreeDOS's own MEM: under dosemu2 the older E801h/88h calls can report + * a much smaller "compatibility" figure than the extended memory pool + * that's actually configured, while E820h reflects it accurately. Fall + * back to E801h (covers configurations above 64MB) and then the even + * older AH=88h (limited to 64MB) only if E820h isn't supported. + */ +static uint32_t query_ext_mem_total_kb(void) +{ + uint32_t total_kb = 0; + uint32_t continuation = 0; + int i; + + for (i = 0; i < E820_MAX_ENTRIES; i++) + { + struct e820_entry entry = {}; + __dpmi_regs r = {}; + + r.d.eax = 0xe820; + r.d.edx = 0x534d4150; /* 'SMAP' */ + r.d.ecx = sizeof(entry); + r.d.ebx = continuation; + r.x.es = __tb_segment; + r.d.edi = __tb_offset; + __dpmi_int(0x15, &r); + + if ((r.x.flags & 1) || r.d.eax != 0x534d4150 || r.d.ecx == 0) + break; + + dosmemget(__tb, r.d.ecx < sizeof(entry) ? r.d.ecx : sizeof(entry), &entry); + + if (entry.type == E820_TYPE_USABLE && entry.base >= 0x100000) + total_kb += (uint32_t)(entry.length / 1024); + + continuation = r.d.ebx; + if (continuation == 0) + break; + } + + if (total_kb > 0) + return total_kb; + + __dpmi_regs r = {}; + r.x.ax = 0xe801; + __dpmi_int(0x15, &r); + if (!(r.x.flags & 1)) + { + uint32_t below_16m = r.x.ax ? r.x.ax : r.x.cx; + uint32_t above_16m_64k_blocks = r.x.ax ? r.x.bx : r.x.dx; + uint32_t total = below_16m + above_16m_64k_blocks * 64; + if (total > 0) + return total; + } + + __dpmi_regs r2 = {}; + r2.h.ah = 0x88; + __dpmi_int(0x15, &r2); + if (!(r2.x.flags & 1) && r2.x.ax > 0) + return r2.x.ax; + + return 0; +} + +static void show_help(void) +{ + reset_page(); + print_line("Displays the amount of used and free memory in your system."); + print_line(""); + print_line("MEM [/CLASSIFY | /FREE | /DEBUG] [/PAGE]"); + print_line(""); + print_line(" /CLASSIFY or /C Classifies programs by memory usage."); + print_line(" /FREE or /F Lists free memory blocks."); + print_line(" /DEBUG or /D Displays detailed memory listing."); + print_line(" /PAGE or /P Pauses after each screen of information."); +} + +void perform_mem(const char *arg) +{ + /* + * Built-in commands are handed only the first argument, while MEM + * takes a combination of switches - "mem /d /p" used to arrive here + * as just "/d", silently dropping /PAGE - so parse the full argument + * string instead. + */ + const char *p = get_cmd_args(); + + opt_classify = 0; + opt_free = 0; + opt_debug = 0; + opt_page = 0; + + while (*p) + { + while (isspace((unsigned char)*p)) + p++; + if (!*p) + break; + if (*p == '/' || *p == '-') + { + p++; + if (*p == '?') + { + show_help(); + return; + } + + const char *tok = p; + while (isalnum((unsigned char)*p)) + p++; + size_t tok_len = p - tok; + + if ((tok_len == 1 && strnicmp(tok, "C", 1) == 0) || + (tok_len == 8 && strnicmp(tok, "CLASSIFY", 8) == 0)) + opt_classify = 1; + else if ((tok_len == 1 && strnicmp(tok, "F", 1) == 0) || + (tok_len == 4 && strnicmp(tok, "FREE", 4) == 0)) + opt_free = 1; + else if ((tok_len == 1 && strnicmp(tok, "D", 1) == 0) || + (tok_len == 5 && strnicmp(tok, "DEBUG", 5) == 0)) + opt_debug = 1; + else if ((tok_len == 1 && strnicmp(tok, "P", 1) == 0) || + (tok_len == 4 && strnicmp(tok, "PAGE", 4) == 0)) + opt_page = 1; + else if (tok_len == 4 && strnicmp(tok, "HELP", 4) == 0) + { + show_help(); + return; + } + else + { + cprintf("Invalid switch - %s\r\n", tok - 1); + reset_batfile_call_stack(); + return; + } + } + else + { + /* not a switch - skip the whole word, not just one character */ + while (*p && !isspace((unsigned char)*p)) + p++; + } + } + + reset_page(); + + /* + * Save UMB link state and link UMBs for the memory scan. This talks + * to INT 21h/58h(03h) (Set UMB Link State) directly instead of going + * through link_umb()/unlink_umb() in umb.c: those also poke the + * allocation strategy via 58h(01h), and unlink_umb() hardcodes it + * back to 0 rather than whatever it was before MEM ran. Doing the + * link/unlink here means MEM never touches allocation strategy at + * all, so there's nothing to restore. + */ + __dpmi_regs r = {}; + r.x.ax = 0x5802; + __dpmi_int(0x21, &r); + uint8_t orig_umblink = r.h.al; + + r.x.ax = 0x5803; + r.x.bx = 1; + __dpmi_int(0x21, &r); + + /* Get List of Lists -> First MCB */ + r.h.ah = 0x52; + __dpmi_int(0x21, &r); + uint32_t sysvars_addr = (r.x.es << 4) + r.x.bx; + uint16_t first_mcb_seg = _farpeekw(_dos_ds, sysvars_addr - 2); + + uint32_t conv_total_kb = 0; + uint32_t conv_total_paras = 0; + uint32_t conv_free_paras = 0; + uint32_t conv_largest_paras = 0; + + uint32_t umb_free_paras = 0; + uint32_t umb_largest_paras = 0; + + /* runs of adjacent free blocks, see below */ + uint32_t conv_run_paras = 0; + uint32_t umb_run_paras = 0; + + struct module_entry modules[64]; + int num_modules = 0; + struct psp_name psp_names[MAX_PSP_NAMES]; + int num_psp_names; + + memset(modules, 0, sizeof(modules)); + memset(psp_names, 0, sizeof(psp_names)); + num_psp_names = collect_psp_names(first_mcb_seg, psp_names, MAX_PSP_NAMES); + + /* + * Memory below the first MCB - the interrupt vector table, the BIOS + * data area and the resident kernel - belongs to no block, but it is + * in use, and MEM.EXE counts it towards SYSTEM. Start the module list + * with it, so the listing adds up to the conventional total and opens + * with SYSTEM, as MEM.EXE's does. + */ + if (opt_classify) + { + modules[0].owner_psp = 8; + strcpy(modules[0].name, "SYSTEM"); + modules[0].conv_paras = first_mcb_seg; + num_modules = 1; + } + + char line_buf[160]; + + if (opt_debug) + { + print_line("Memory Detail:"); + print_line(" Segment Owner Size Type/Name"); + print_line(" ------- ------- -------- -----------"); + } + else if (opt_free) + { + print_line("Free Memory Blocks:"); + print_line(" Segment Size Memory Type"); + print_line(" ------- -------- -----------"); + } + + uint16_t curr_seg = first_mcb_seg; + while (curr_seg != 0) + { + struct MCB mcb; + dosmemget(curr_seg << 4, sizeof(mcb), &mcb); + + if (mcb.id != 'M' && mcb.id != 'Z') + break; + + uint32_t block_paras = mcb.size; + int is_umb = (curr_seg >= UMB_START_SEG); + int is_link = mcb_is_link(&mcb); + + /* + * A block can cross the 640K line: with UMBs linked in, DOS merges + * the top of conventional memory with an adjacent upper memory + * block into a single MCB. Count each half against the region it + * lies in, or conventional memory ends up bigger than 640K. + */ + uint32_t conv_paras = 0; + uint32_t umb_paras = 0; + + if (is_umb) + umb_paras = block_paras; + else if (curr_seg + block_paras <= UMB_START_SEG) + conv_paras = block_paras; + else + { + conv_paras = UMB_START_SEG - curr_seg; + umb_paras = block_paras - conv_paras; + } + + if (is_link) + { + /* a hole in the address space: listed, but not counted */ + conv_paras = 0; + umb_paras = 0; + } + + char name[16]; + get_mcb_name(&mcb, psp_names, num_psp_names, name, sizeof(name)); + + /* the MCB header itself belongs to the region it sits in */ + if (!is_umb) + conv_total_paras += conv_paras + 1; + + if (mcb.owner_psp == 0) + { + conv_free_paras += conv_paras; + umb_free_paras += umb_paras; + + /* + * DOS merges adjacent free blocks when it allocates, so a run of + * them is a single allocatable block - and the MCB headers + * between them become usable too. Tracking runs rather than + * single blocks is what makes the largest sizes agree with what + * MEM.EXE reports: right after a program exits, the memory it + * used is several adjacent free blocks that DOS has not merged + * yet. + */ + if (conv_paras) + { + conv_run_paras = conv_run_paras ? conv_run_paras + 1 + conv_paras : + conv_paras; + if (conv_run_paras > conv_largest_paras) + conv_largest_paras = conv_run_paras; + } + else + conv_run_paras = 0; + + if (umb_paras) + { + umb_run_paras = umb_run_paras ? umb_run_paras + 1 + umb_paras : + umb_paras; + if (umb_run_paras > umb_largest_paras) + umb_largest_paras = umb_run_paras; + } + else + umb_run_paras = 0; + } + else + { + conv_run_paras = 0; + umb_run_paras = 0; + } + + /* Collect module info for /CLASSIFY */ + if (opt_classify && !is_link) + { + int found = -1; + int i; + for (i = 0; i < num_modules; i++) + { + if (modules[i].owner_psp == mcb.owner_psp && strcmp(modules[i].name, name) == 0) + { + found = i; + break; + } + } + if (found == -1 && num_modules < 64) + { + found = num_modules++; + modules[found].owner_psp = mcb.owner_psp; + strncpy(modules[found].name, name, sizeof(modules[found].name) - 1); + } + if (found >= 0) + { + if (is_umb) + modules[found].umb_paras += umb_paras + 1; + else + { + modules[found].conv_paras += conv_paras + 1; + modules[found].umb_paras += umb_paras; + } + } + } + + if (opt_debug) + { + char sz_str[24]; + format_kb(paras_to_kb(block_paras), sz_str, sizeof(sz_str)); + snprintf(line_buf, sizeof(line_buf), " %04Xh %04Xh %8s %s", + curr_seg, mcb.owner_psp, sz_str, name); + print_line(line_buf); + } + else if (opt_free && mcb.owner_psp == 0) + { + char sz_str[24]; + format_kb(paras_to_kb(block_paras), sz_str, sizeof(sz_str)); + snprintf(line_buf, sizeof(line_buf), " %04Xh %8s %s", + curr_seg, sz_str, is_umb ? "Upper" : "Conventional"); + print_line(line_buf); + } + + if (mcb.id == 'Z') + break; + + if (curr_seg + 1 + mcb.size > 0xffff) + break; /* corrupt chain: don't wrap around into low memory */ + curr_seg += 1 + mcb.size; + } + + if (!orig_umblink) + { + r.x.ax = 0x5803; + r.x.bx = 0; + __dpmi_int(0x21, &r); + } + + if (page_stop) + return; + + /* + * The MCB arena walked above starts after the resident DOS kernel, + * interrupt vector table and BIOS data area, so its sum is a few KB + * short of the true installed conventional memory. INT 12h reports + * the BIOS-detected total in KB directly, which is what real MEM.COM + * shows as "Total conventional memory". + */ + __dpmi_regs ir = {}; + __dpmi_int(0x12, &ir); + if (ir.x.ax > 0) + conv_total_kb = ir.x.ax; + else + conv_total_kb = conv_total_paras ? paras_to_kb(conv_total_paras) : CONV_MAX_KB; + if (conv_total_kb > CONV_MAX_KB) + conv_total_kb = CONV_MAX_KB; + + uint32_t conv_free_kb = paras_to_kb(conv_free_paras); + uint32_t conv_used_kb = (conv_total_kb > conv_free_kb) ? + (conv_total_kb - conv_free_kb) : 0; + uint32_t umb_total_kb = UMB_TOTAL_KB; + uint32_t umb_free_kb = paras_to_kb(umb_free_paras); + uint32_t umb_used_kb = (umb_total_kb > umb_free_kb) ? + (umb_total_kb - umb_free_kb) : 0; + + if (opt_classify) + { + print_line(""); + print_line("Modules using memory below 1 MB:"); + print_line(""); + print_line(" Name Total Conventional Upper Memory"); + print_line(" -------- -------- ------------ ------------"); + + /* loaded modules in the order they sit in memory, free memory last, + * the way MEM.EXE lists them */ + int pass, i; + for (pass = 0; pass < 2; pass++) + { + for (i = 0; i < num_modules; i++) + { + char t_str[24], c_str[24], u_str[24]; + int is_free = (modules[i].owner_psp == 0); + + if (is_free != pass) + continue; + + format_kb(paras_to_kb(modules[i].conv_paras + modules[i].umb_paras), + t_str, sizeof(t_str)); + format_kb(paras_to_kb(modules[i].conv_paras), c_str, sizeof(c_str)); + format_kb(paras_to_kb(modules[i].umb_paras), u_str, sizeof(u_str)); + + snprintf(line_buf, sizeof(line_buf), " %-8s %8s %12s %12s", + modules[i].name, t_str, c_str, u_str); + print_line(line_buf); + } + } + print_line(""); + } + + /* Query XMS */ + uint32_t xms_free_kb = 0; + int xms_present = 0; + + __dpmi_regs dr = {}; + dr.x.ax = 0x4300; + __dpmi_int(0x2f, &dr); + if ((dr.h.al & 0xff) == 0x80) + { + xms_present = 1; + dr.x.ax = 0x4310; + __dpmi_int(0x2f, &dr); + __dpmi_raddr xms_entry; + xms_entry.segment = dr.x.es; + xms_entry.offset16 = dr.x.bx; + + __dpmi_regs xr = {}; + xr.h.ah = 0x08; /* Query Free Extended Memory */ + xr.x.cs = xms_entry.segment; + xr.x.ip = xms_entry.offset16; + __dpmi_simulate_real_mode_procedure_retf(&xr); + + if (xr.h.bl == 0) + xms_free_kb = xr.x.dx; + } + + uint32_t xms_total_kb = query_ext_mem_total_kb(); + + if (!xms_present && xms_total_kb > 0) + { + /* No XMS manager loaded, so nothing has claimed any of it yet. */ + xms_free_kb = xms_total_kb; + xms_present = 1; + } + + if (xms_total_kb < xms_free_kb) + { + /* + * The BIOS-reported extended memory size is a legacy/compatibility + * figure and can be smaller than the pool the XMS manager actually + * hands out (observed under dosemu2, whose virtual XMS pool isn't + * tied to what INT 15h reports). Never show a total smaller than + * what XMS itself claims is free - that would be a nonsensical + * "free > total" display. + */ + xms_total_kb = xms_free_kb; + } + + uint32_t xms_used_kb = xms_total_kb - xms_free_kb; + + /* Query EMS */ + uint32_t ems_total_kb = 0; + uint32_t ems_free_kb = 0; + int ems_present = 0; + + __dpmi_raddr int67; + __dpmi_get_real_mode_interrupt_vector(0x67, &int67); + if (int67.segment != 0) + { + __dpmi_regs er = {}; + er.h.ah = 0x40; + __dpmi_int(0x67, &er); + if (er.h.ah == 0) + { + er.h.ah = 0x42; + __dpmi_int(0x67, &er); + if (er.h.ah == 0) + { + ems_present = 1; + ems_free_kb = (uint32_t)er.x.bx * EMS_PAGE_KB; + ems_total_kb = (uint32_t)er.x.dx * EMS_PAGE_KB; + } + } + } + + /* Print Memory Summary Table */ + char str_tot[24], str_used[24], str_free[24]; + + print_line(""); + print_line("Memory Type Total Used Free"); + print_line("---------------- -------- -------- --------"); + + format_kb(conv_total_kb, str_tot, sizeof(str_tot)); + format_kb(conv_used_kb, str_used, sizeof(str_used)); + format_kb(conv_free_kb, str_free, sizeof(str_free)); + snprintf(line_buf, sizeof(line_buf), "%-16s %8s %8s %8s", + "Conventional", str_tot, str_used, str_free); + print_line(line_buf); + + format_kb(umb_total_kb, str_tot, sizeof(str_tot)); + format_kb(umb_used_kb, str_used, sizeof(str_used)); + format_kb(umb_free_kb, str_free, sizeof(str_free)); + snprintf(line_buf, sizeof(line_buf), "%-16s %8s %8s %8s", + "Upper", str_tot, str_used, str_free); + print_line(line_buf); + + format_kb(RESERVED_TOTAL_KB, str_tot, sizeof(str_tot)); + format_kb(0, str_free, sizeof(str_free)); + snprintf(line_buf, sizeof(line_buf), "%-16s %8s %8s %8s", + "Reserved", str_tot, str_tot, str_free); + print_line(line_buf); + + if (xms_present) + { + format_kb(xms_total_kb, str_tot, sizeof(str_tot)); + format_kb(xms_used_kb, str_used, sizeof(str_used)); + format_kb(xms_free_kb, str_free, sizeof(str_free)); + snprintf(line_buf, sizeof(line_buf), "%-16s %8s %8s %8s", + "Extended (XMS)", str_tot, str_used, str_free); + print_line(line_buf); + } + + print_line("---------------- -------- -------- --------"); + + format_kb(conv_total_kb + umb_total_kb + RESERVED_TOTAL_KB + xms_total_kb, + str_tot, sizeof(str_tot)); + format_kb(conv_used_kb + umb_used_kb + RESERVED_TOTAL_KB + xms_used_kb, + str_used, sizeof(str_used)); + format_kb(conv_free_kb + umb_free_kb + xms_free_kb, str_free, sizeof(str_free)); + snprintf(line_buf, sizeof(line_buf), "%-16s %8s %8s %8s", + "Total memory", str_tot, str_used, str_free); + print_line(line_buf); + + print_line(""); + + /* the reserved region is left out here, as MEM.EXE leaves it out */ + format_kb(conv_total_kb + umb_total_kb, str_tot, sizeof(str_tot)); + format_kb(conv_used_kb + umb_used_kb, str_used, sizeof(str_used)); + format_kb(conv_free_kb + umb_free_kb, str_free, sizeof(str_free)); + snprintf(line_buf, sizeof(line_buf), "%-16s %8s %8s %8s", + "Total under 1 MB", str_tot, str_used, str_free); + print_line(line_buf); + + print_line(""); + + if (ems_present) + { + print_kb_and_bytes("Total Expanded (EMS)", ems_total_kb, ems_total_kb * 1024); + print_kb_and_bytes("Free Expanded (EMS)", ems_free_kb, ems_free_kb * 1024); + print_line(""); + } + + print_kb_and_bytes("Largest executable program size", + paras_to_kb(conv_largest_paras), conv_largest_paras * 16); + print_kb_and_bytes("Largest available upper memory block", + paras_to_kb(umb_largest_paras), umb_largest_paras * 16); +} diff --git a/src/mem.h b/src/mem.h new file mode 100644 index 0000000..eae72cd --- /dev/null +++ b/src/mem.h @@ -0,0 +1,25 @@ +/* + * comcom64 - 64bit command.com + * mem.h: FreeDOS-like MEM command header + * Copyright (C) 2026 @stsp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef MEM_H +#define MEM_H + +void perform_mem(const char *arg); + +#endif diff --git a/tests/compare_mem.sh b/tests/compare_mem.sh new file mode 100755 index 0000000..6542acf --- /dev/null +++ b/tests/compare_mem.sh @@ -0,0 +1,212 @@ +#!/bin/sh +# compare_mem.sh: run comcom64's built-in MEM against the real FreeDOS +# MEM.EXE, in the same dosemu2 session, and print both outputs so the +# numbers can be compared by hand (or diffed by a future caller). +# +# Both dosemu2 and FreeDOS come from the dosemu2 PPA: install dosemu2 +# and install-freedos from ppa:dosemu2/ppa (see ci_test_prereq.sh), and +# this script then runs dosemu2's own "insfdusr" to install the FreeDOS +# userspace - which is where MEM.EXE comes from - instead of downloading +# and unpacking mem.zip by hand. +# +# The FreeDOS installation lives in a private HOME under $CACHE_DIR, so +# it is done once and reused, and the caller's own ~/.dosemu is left +# alone. +# +# The same comparison is done by dosemu2's own test suite, in +# test/func_comcom_internal.py, which CI runs - this script is the +# stand-alone version of it, for looking at the two reports side by side. +# +# This does NOT try to byte-for-byte diff the two outputs: they format +# the detail listings differently and, since MEM.EXE is loaded as an external program +# while comcom64's MEM is built into the shell, the two tools observe +# slightly different conventional-memory states (MEM.EXE's own PSP, +# environment and program taking up space that our built-in command +# doesn't need). We run our own MEM first so it always sees the +# pristine, nothing-else-loaded state, then MEM.EXE second. +# +# What IS asserted automatically: the figures that are independent of +# which tool loaded them - conventional and upper totals, the reserved +# region, XMS total/used/free, EMS total/free, free upper memory and the +# largest free upper block. Conventional used/free differ by about a KB, +# which is MEM.EXE's own PSP and environment - our MEM is built into the +# shell and needs neither - so they are not asserted here. +# +# Usage: ./tests/compare_mem.sh [path/to/comcom64-build] +# The argument is the comcom64 to test: either a directory holding +# command.com (as "make install" produces), or a comcom64.exe file. +# It defaults to the build in src/, built via "make" if not there yet. +# Without an argument and without a build, the comcom64 that dosemu2 +# is configured with is used as-is. +# +# Env vars: +# CACHE_DIR - where to keep the FreeDOS installation used for the +# comparison (default: ~/.cache/comcom64-tests) +# EMS_SIZE_KB - EMS pool size to configure dosemu with (default: 4096) + +set -e + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) + +CACHE_DIR=${CACHE_DIR:-"$HOME/.cache/comcom64-tests"} +EMS_SIZE_KB=${EMS_SIZE_KB:-4096} + +if ! command -v dosemu >/dev/null; then + echo "dosemu2 not found - install it from ppa:dosemu2/ppa:" >&2 + echo " sudo add-apt-repository ppa:dosemu2/ppa" >&2 + echo " sudo apt install dosemu2 install-freedos" >&2 + exit 1 +fi + +# The comcom64 under test. dosemu2 picks the shell up from +# $DOSEMU2_COMCOM_DIR, so a build can be tested without installing it. +COMCOM=${1:-} +if [ -z "$COMCOM" ] && [ ! -f "$REPO_ROOT/src/comcom64.exe" ] && + [ ! -f "$REPO_ROOT/src/libtmp.so" ]; then + echo "== no build in src/, building it ==" + make -C "$REPO_ROOT/src" -j"$(nproc)" || make -C "$REPO_ROOT" static +fi +if [ -z "$COMCOM" ]; then + if [ -f "$REPO_ROOT/src/comcom64.exe" ]; then + COMCOM="$REPO_ROOT/src/comcom64.exe" + else + COMCOM="$REPO_ROOT/src/libtmp.so" + fi +fi + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +COMCOM_DIR="$WORKDIR/comcom" +mkdir -p "$COMCOM_DIR" +if [ -d "$COMCOM" ]; then + cp -a "$COMCOM"/. "$COMCOM_DIR/" +elif [ "${COMCOM%.so}" != "$COMCOM" ]; then + # dj64 static build: the loader runs the ELF next to it + ELFLOAD=$(pkg-config --variable=elfload dj64) + cp "$COMCOM" "$COMCOM_DIR/command.elf" + cp "$ELFLOAD" "$COMCOM_DIR/command.com" +else + cp "$COMCOM" "$COMCOM_DIR/command.com" +fi +echo "== testing comcom64 from $COMCOM ==" +export DOSEMU2_COMCOM_DIR="$COMCOM_DIR" + +# Private HOME: insfdusr installs FreeDOS into $HOME/.dosemu/drive_c. +TEST_HOME="$CACHE_DIR/home" +mkdir -p "$TEST_HOME" +export HOME="$TEST_HOME" + +if [ ! -f "$TEST_HOME/.dosemu/drive_c/bin/mem.exe" ]; then + echo "== installing the FreeDOS userspace with insfdusr ==" + timeout 600 dosemu -td -o "$WORKDIR/insfdusr.log" -E insfdusr \ + > "$WORKDIR/insfdusr.out" 2>&1 || { + echo "insfdusr failed:" >&2 + cat "$WORKDIR/insfdusr.out" >&2 + exit 1 + } + if [ ! -f "$TEST_HOME/.dosemu/drive_c/bin/mem.exe" ]; then + echo "insfdusr did not install MEM.EXE - is install-freedos installed?" >&2 + exit 1 + fi +fi + +cat > "$TEST_HOME/.dosemu/drive_c/cmpmem.bat" <<'EOF' +@echo off +echo ===COMCOM64-DEFAULT=== +mem +echo ===COMCOM64-DEBUG=== +mem /debug +echo ===COMCOM64-FREE=== +mem /free +echo ===COMCOM64-CLASSIFY=== +mem /classify +echo ===FDMEM-DEFAULT=== +c:\bin\mem.exe +echo ===FDMEM-FREE=== +c:\bin\mem.exe /free +echo ===FDMEM-CLASSIFY=== +c:\bin\mem.exe /classify +EOF + +OUT="$WORKDIR/output.txt" +timeout 120 dosemu -td \ + -e "$EMS_SIZE_KB" \ + -o "$WORKDIR/boot.log" \ + -E "cmpmem.bat" > "$OUT" 2>&1 || { + echo "dosemu run failed; boot.log follows:" >&2 + cat "$WORKDIR/boot.log" >&2 + exit 1 + } + +echo +echo "############################################################" +echo "# Full output (comcom64 first, then real FreeDOS MEM.EXE)" +echo "############################################################" +sed -n '/===COMCOM64-DEFAULT===/,$p' "$OUT" +echo + +sed -n '/===COMCOM64-DEFAULT===/,/===COMCOM64-DEBUG===/p' "$OUT" > "$WORKDIR/comcom.txt" +sed -n '/===FDMEM-DEFAULT===/,/===FDMEM-FREE===/p' "$OUT" > "$WORKDIR/fdmem.txt" + +# Both tools report the summary in KB now, so the figures can be compared +# directly. FreeDOS MEM prints "637K", comcom64 prints the same - strip +# the separators and the K suffix and compare the numbers. +field() { + # $1 = file, $2 = row regex, $3 = column (1=total, 2=used, 3=free) + grep -E "$2" "$1" | head -1 | sed -E 's/^.{16}//' | tr -d '\r' | + awk -v col="$3" '{print $col}' | tr -d ',K' +} + +status=0 +echo "############################################################" +echo "# Automated checks (values that must match exactly)" +echo "############################################################" + +check() { + name=$1; a=$2; b=$3 + if [ "$a" = "$b" ] && [ -n "$a" ]; then + echo "PASS: $name matches (${a}K)" + else + echo "FAIL: $name differs: comcom64=${a}K freedos=${b}K" + status=1 + fi +} + +check_row() { + # $1 = label, $2 = row regex, $3 = column + check "$1" "$(field "$WORKDIR/comcom.txt" "$2" "$3")" \ + "$(field "$WORKDIR/fdmem.txt" "$2" "$3")" +} + +check_row "conventional total" '^Conventional' 1 +check_row "upper total" '^Upper' 1 +check_row "upper used" '^Upper' 2 +check_row "upper free" '^Upper' 3 +check_row "reserved total" '^Reserved' 1 +check_row "reserved used" '^Reserved' 2 +check_row "XMS total" '^Extended \(XMS\)' 1 +check_row "XMS used" '^Extended \(XMS\)' 2 +check_row "XMS free" '^Extended \(XMS\)' 3 +check_row "total under 1 MB" '^Total under 1 MB' 1 + +tail_field() { + # $1 = file, $2 = label regex: the KB figure of a "label NNNK" line. + # FreeDOS MEM appends the byte count in brackets, so pick the first + # number that carries the K suffix rather than the last field. + grep -E "$2" "$1" | head -1 | tr -d '\r' | + grep -oE '[0-9][0-9,]*K' | head -1 | tr -d ',K' +} + +check "EMS total" "$(tail_field "$WORKDIR/comcom.txt" '^Total Expanded \(EMS\)')" \ + "$(tail_field "$WORKDIR/fdmem.txt" '^Total Expanded \(EMS\)')" +check "EMS free" "$(tail_field "$WORKDIR/comcom.txt" '^Free Expanded \(EMS\)')" \ + "$(tail_field "$WORKDIR/fdmem.txt" '^Free Expanded \(EMS\)')" +# MEM.EXE calls this row "Largest free upper memory block", ours keeps +# the MS-DOS wording that the dosemu2 test suite looks for. +check "largest upper memory block" \ + "$(tail_field "$WORKDIR/comcom.txt" '^Largest available upper')" \ + "$(tail_field "$WORKDIR/fdmem.txt" '^Largest free upper')" + +exit $status