Sidestepping the question: Allocate resident block without a PSP
My TSRs RxANSI and lClock do away with the PSP entirely.
First, the transient program frees the environment block. Then it relocates itself (process/PSP and all) to the top of the LMA, to avoid fragmentation later. This requires hacking the MCB of the target PSP so that it is self-owned, and correctly setting up the old process's parent return address, parent PSP, and the new parent's DOS call stack address (dword at offset 2Eh). Also take care to transfer the open file handles properly; you want the relocated process to retain its stdout and stderr handles. You also need to either close file handles only once, or DUP them with functions 26h, 55h, 45h, or 46h. Relocation is finished by terminating the old PSP with the setup that will return into the new PSP.
Next, the (relocated) transient part allocates a memory block for the resident part. (It can enable the UMB link and set up the allocation strategy so as to use UMBs, if found, so LH isn't needed.) Finally before terminating its process it hacks the resident block's MCB to be self owned. (The termination happens to close all the process's file handles too. If you do retain a PSP you generally want to close all file handles besides freeing the environment; imagine what happens if the user runs
tsr > NUL - if the TSR does not free its handles then an SFT entry is leaked!)
As the resident block is self-owned after this (
word [MCB segment:1] equal to
MCB segment + 1), it shows up like a regular TSR in
MEM output. In particular, its program name is read from the (zero-byte-padded) 8 bytes stored in the MCB at offset 8. However, this may also make software assume that there should be a PSP when there isn't. (A better check for PSPs is "self-owned memory block with the content starting with the two bytes
CDh 20h" (an
int 20h instruction, found at the start of every PSP).)
As indicated by the other answer, you can use a number of special values as the MCB owner. The value must be nonzero because zero indicates a free MCB. The value 8 is well-known for DOS data structures (first two name bytes contain "SD"), DOS code or excluded UMA ranges ("SC"), or other system usages. A solid check for system values can consider all values below 50h as indicating a "special" owner. Otherwise, and assumed by many programs for any non-zero non-8 value, the value is taken as a segment of a program. In particular, the program name is read from what is assumed to be an MCB at the segment one below the indicated owner value (as a segment).
Actual answer: Probing the "minimum PSP size" as indicated by the system
There is a place in DOS that gives an "authoritative" answer to the actual question: The function 31h handler enforces a minimum size for the PSP. This is 60h bytes in both MS-DOS 2 and in FreeDOS. Crucially, you can test this: create a dummy PSP and call function 31h with
dx = 0. Like with PSP relocation, you can free the environment first. Like with an actual TSR with a PSP, you have to take care to close file handles if you DUP them. In my example I do not DUP the file handles but rather initialise the PHT to 20 minus-one entries in the test PSP.
I uploaded a test program to my repos as testpsps. To build it, check out the lmacros repo and this repo, then assemble with NASM. On a successful run it displays the amount of paragraphs that the test PSP retains after an interrupt 21h function 31h call with
dx equal to zero. As expected this is 6 paragraphs running on the FreeDOS kernel 2042.
Ralf Brown's AMISLIB has an opinion on this, too
In the AMISLIB version 0.92 as released by Ralf Brown, one of the possible locations to which to install a newly resident program is the current PSP. It will preserve the first 40h (= 64) bytes of the PSP in that case, and copy the resident section to the PSP segment + 4. This is from AMIS.ASM, function
; as a last resort, use our own PSP to store the code, and go resident
add ax,4 ; copy to offset 40h in PSP
push ax ; remember where we'll relocate
mov exit_func,31h ; TSR rather than normal exit
xchg ax,es:[002Ch] ; get and zero environment segment
mov ah,49h ; since we will be going resident,
int 21h ; discard our environment
pop ax ; get back destination segment
; relocate TSR code into the PSP or UMB
; at this point, AX must be the segment at which to relocate
pop cx ; get back TSR code size in paragraphs
pop bx ; get back TSR code segment
mov es,ax ; ES -> resident_seg
mov ax,16 ; bytes per paragraph
mul cx ; get size in bytes
jnz install_failed_pop ; can only handle 64K at this time
mov cx,ax ; number of bytes to copy
rep movsb ; copy the TSR's code
And the example program NOTE.ASM uses a resident PSP that it creates in the first 40h bytes of its resident code segment. This "TSR PSP" has a particularly noteworthy comment:
; Since we need a PSP, but might be loaded into a UMB or at the top of
; conventional memory, we make a copy of the all-important first 64 bytes
; of the PSP here. After relocation, this copy will start at offset 0
TSR_PSP db 64 dup (?)
So Ralf Brown certainly was of the opinion that only the first 64 bytes of the PSP really mattered.